Skip to content

osrlib.core.abilities

What a character's six ability scores are worth, and what you can do with them.

Start with load_ability_tables, which gives you an AbilityTables with the SRD's printed tables. Hand any of its accessors a score from 3 to 18 and get the number the rules apply: the melee modifier for strength, the armour class modifier for dexterity, the hit points per Hit Die for constitution, and so on. A Character exposes the ones it needs as properties, so you call the accessors yourself when you're working outside a character.

Two dice functions live here as well. ability_check rolls the SRD's generic check for a task the rules don't otherwise cover, and open_doors_check rolls a strength-based attempt to force a stuck door. Both take an RngStream.

The rest of the module is character creation's third step, where a player trades points between abilities before play. validate_adjustment says whether a proposed trade is legal and apply_adjustment carries it out. create_character runs the whole creation sequence, so reach for these two when you're building a character step by step and letting a player choose.

Scores run 3 to 18. That's what 3d6 can roll, what the SRD's tables list, and what the adjustment step has to stay inside.

Typical usage:

from osrlib.core.abilities import ability_check
from osrlib.core.rng import RngStreams
from osrlib.data import load_ability_tables

tables = load_ability_tables()

# What a strength of 16 is worth.
assert tables.melee_modifier(16) == 2
assert tables.open_doors_chance(16) == 4

# A check against a dexterity of 13, on a stream you supply.
check = ability_check(13, RngStreams(master_seed=3).get("exploration"))
assert (check.roll, check.success) == (18, False)

ADJUSTMENT_FLOOR module-attribute

ADJUSTMENT_FLOOR = 9

The lowest a score may be traded down to during character creation, which is 9.

The floor stops a player from emptying one ability to buy up another. It applies only to the trade in validate_adjustment. A score rolled below 9 is legal, and it cannot be lowered further.

MAX_SCORE module-attribute

MAX_SCORE = 18

The highest an ability score can be, which is 18.

Eighteen is what three dice showing 6 add up to, the top row of every table here, and the ceiling the creation-time trade may not push a score past. The accessors raise ValueError above it, because the tables print no row to read.

MIN_SCORE module-attribute

MIN_SCORE = 3

The lowest an ability score can be, which is 3.

Three is what three dice showing 1 add up to, and the lowest row of every table here. Below it there's no rule to apply, so the accessors and the check functions raise ValueError rather than guess. Use it to bound a slider, or to check a score your own code produced.

AbilityAdjustment

Bases: BaseModel

A proposed trade of ability points, made once while a character is being created.

Build one from what the player chose, check it with validate_adjustment, and carry it out with apply_adjustment. The exchange rate is two points down for one point up, and the points bought can only go into the class's prime requisites, the abilities the class is built around.

An adjustment with nothing in it is legal and changes nothing, which is what you build for a player who keeps the scores as rolled.

Examples:

from osrlib.core.abilities import AbilityAdjustment, AbilityScore

# Four points out of intelligence and wisdom buys two points of strength.
adjustment = AbilityAdjustment(
    lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2},
    raised={AbilityScore.STR: 2},
)
assert sum(adjustment.raised.values()) == sum(adjustment.lowered.values()) // 2

lowered class-attribute instance-attribute

lowered: dict[AbilityScore, int] = {}

How much to take off each ability, as a positive number.

Only strength, intelligence, and wisdom may appear, each amount must be even, and no score may end below ADJUSTMENT_FLOOR.

raised class-attribute instance-attribute

raised: dict[AbilityScore, int] = {}

How much to add to each ability, as a positive number.

Only the class's prime requisites may appear, the amounts must add up to half the points taken off, and no score may end above MAX_SCORE.

AbilityCheckResult

Bases: BaseModel

How an ability check turned out, with the die kept so you can show it.

ability_check returns one.

roll instance-attribute

roll: int

What the d20 came up, from 1 to 20, before the modifier.

score instance-attribute

score: int

The ability score the roll was checked against.

modifier instance-attribute

modifier: int

The difficulty modifier that was applied to the roll. Positive made it harder.

success instance-attribute

success: bool

Whether the check succeeded.

True when the modified roll came out at or under the score, and on a natural 1 whatever the score.

AbilityScore

Bases: StrEnum

Which of the six abilities a score belongs to.

Use these as the keys of a score dictionary, which is how every function here and in osrlib.core.character passes a character's abilities around. All six keys are expected to be present.

The lowercase values serialize into characters and saved games. Changing one is a schema_version bump, the version stamp that marks a serialized model's shape.

STR class-attribute instance-attribute

STR = 'str'

Strength: melee attack and damage, and the chance to force a stuck door open.

INT class-attribute instance-attribute

INT = 'int'

Intelligence: how many extra languages a character speaks, and whether they can read and write.

WIS class-attribute instance-attribute

WIS = 'wis'

Wisdom: the modifier on saving throws against magical effects.

DEX class-attribute instance-attribute

DEX = 'dex'

Dexterity: armour class, missile attacks, and initiative under the individual-initiative rule.

CON class-attribute instance-attribute

CON = 'con'

Constitution: the hit points added to each Hit Die rolled.

CHA class-attribute instance-attribute

CHA = 'cha'

Charisma: how monsters and NPCs react, and how many retainers will follow the character, how loyally.

AbilityTables

Bases: BaseModel

The SRD's ability tables, and the accessors that read a score out of them.

Get one from load_ability_tables, which loads the tables that ship with the package and caches them, so calling it repeatedly costs nothing. Then call the accessor for the column you want, passing a score from MIN_SCORE to MAX_SCORE. A score outside that range raises ValueError, because a score outside it is a mistake in your code rather than an outcome the rules allow.

A Character reads these tables for you and offers the results as properties, so use the accessors when you have a bare score and no character.

The fields contain the raw rows. Read them to draw a table, and use the accessors to play.

Examples:

from osrlib.data import load_ability_tables

tables = load_ability_tables()
assert tables.melee_modifier(18) == 3
assert tables.hit_point_modifier(3) == -3

# The rows are there when you want to show the whole table.
assert (tables.strength[1].min_score, tables.strength[1].max_score) == (4, 5)

strength instance-attribute

strength: tuple[StrengthRow, ...]

The strength table's rows, lowest score first, together covering 3 to 18 with no gaps.

intelligence instance-attribute

intelligence: tuple[IntelligenceRow, ...]

The intelligence table's rows, lowest score first, together covering 3 to 18 with no gaps.

wisdom instance-attribute

wisdom: tuple[WisdomRow, ...]

The wisdom table's rows, lowest score first, together covering 3 to 18 with no gaps.

dexterity instance-attribute

dexterity: tuple[DexterityRow, ...]

The dexterity table's rows, lowest score first, together covering 3 to 18 with no gaps.

constitution instance-attribute

constitution: tuple[ConstitutionRow, ...]

The constitution table's rows, lowest score first, together covering 3 to 18 with no gaps.

charisma instance-attribute

charisma: tuple[CharismaRow, ...]

The charisma table's rows, lowest score first, together covering 3 to 18 with no gaps.

prime_requisite instance-attribute

prime_requisite: tuple[PrimeRequisiteRow, ...]

The prime requisite experience table's rows, lowest score first, together covering 3 to 18 with no gaps.

melee_modifier

melee_modifier(score: int) -> int

Return what a strength score adds to melee attack rolls and melee damage.

Missile attacks take the dexterity modifier instead. See missile_modifier.

Parameters:

Name Type Description Default
score int

A strength score from 3 to 18.

required

Returns:

Type Description
int

The modifier, from −3 at a score of 3 to +3 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

open_doors_chance

open_doors_chance(score: int) -> int

Return the chance in 6 that a strength score forces a stuck door open.

Pass the result to open_doors_check, which rolls against it.

Parameters:

Name Type Description Default
score int

A strength score from 3 to 18.

required

Returns:

Type Description
int

The chance in 6, from 1 at a score of 3 to 5 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

additional_languages

additional_languages(score: int) -> int

Return how many languages beyond the native ones an intelligence score grants.

The choices themselves are checked by validate_extra_languages, which reads this same allowance.

Parameters:

Name Type Description Default
score int

An intelligence score from 3 to 18.

required

Returns:

Type Description
int

The count, 0 below a score of 13 and up to 3 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

literacy

literacy(score: int) -> Literacy

Return how well an intelligence score lets a character read and write.

Parameters:

Name Type Description Default
score int

An intelligence score from 3 to 18.

required

Returns:

Type Description
Literacy

The Literacy band for that score.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

magic_save_modifier

magic_save_modifier(score: int) -> int

Return what a wisdom score adds to saving throws against magical effects.

It applies to magical effects only, not to a saving throw against a trap or a dragon's breath.

Parameters:

Name Type Description Default
score int

A wisdom score from 3 to 18.

required

Returns:

Type Description
int

The modifier, from −3 at a score of 3 to +3 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

ac_modifier

ac_modifier(score: int) -> int

Return what a dexterity score is worth to armour class.

The number is a bonus: it's subtracted from a descending armour class, where lower is better, and added to an ascending one. Character.armour_class applies it for you.

Parameters:

Name Type Description Default
score int

A dexterity score from 3 to 18.

required

Returns:

Type Description
int

The bonus, from −3 at a score of 3 to +3 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

missile_modifier

missile_modifier(score: int) -> int

Return what a dexterity score adds to missile attack rolls.

It changes the attack roll only. Missile damage takes no ability modifier.

Parameters:

Name Type Description Default
score int

A dexterity score from 3 to 18.

required

Returns:

Type Description
int

The modifier, from −3 at a score of 3 to +3 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

initiative_modifier

initiative_modifier(score: int) -> int

Return what a dexterity score adds to an individual initiative roll.

It is read only when the individual_initiative flag on Ruleset is on. With the flag off, initiative is rolled once for a whole side and no ability modifier applies.

Parameters:

Name Type Description Default
score int

A dexterity score from 3 to 18.

required

Returns:

Type Description
int

The modifier, from −2 at a score of 3 to +2 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

hit_point_modifier

hit_point_modifier(score: int) -> int

Return what a constitution score adds to every Hit Die a character rolls.

It applies at creation and again at each level gained. However negative it is, a die never ends up granting fewer than 1 hit point.

Parameters:

Name Type Description Default
score int

A constitution score from 3 to 18.

required

Returns:

Type Description
int

The modifier, from −3 at a score of 3 to +3 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

npc_reaction_modifier

npc_reaction_modifier(score: int) -> int

Return what a charisma score adds to a monster or NPC reaction roll.

Add it to the 2d6 total before reading the result with reaction_result, which clamps a modified total into the printed outer bands.

Parameters:

Name Type Description Default
score int

A charisma score from 3 to 18.

required

Returns:

Type Description
int

The modifier, from −2 at a score of 3 to +2 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

max_retainers

max_retainers(score: int) -> int

Return how many hired followers a charisma score lets a character keep at once.

Parameters:

Name Type Description Default
score int

A charisma score from 3 to 18.

required

Returns:

Type Description
int

The count, from 1 at a score of 3 to 7 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

retainer_loyalty

retainer_loyalty(score: int) -> int

Return the loyalty a retainer of a character with this charisma score starts with.

Loyalty is the number a retainer's nerve is tested against when the party asks something risky of them.

Parameters:

Name Type Description Default
score int

A charisma score from 3 to 18.

required

Returns:

Type Description
int

The loyalty score, from 4 at a charisma of 3 to 10 at 18.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

prime_requisite_xp_modifier_pct

prime_requisite_xp_modifier_pct(score: int) -> int

Return the percentage a single prime requisite score changes earned experience by.

A prime requisite is the ability a class is built around. This table covers a class with exactly one. A class with more than one has its own tiers on its class definition, and xp_modifier_pct picks the right source for you.

Parameters:

Name Type Description Default
score int

A prime requisite score from 3 to 18.

required

Returns:

Type Description
int

The percentage, from −20 at a score of 3 to +10 at 16 or above.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

CharismaRow

Bases: ScoreBand

One row of the charisma table, with the three things charisma grants.

npc_reactions instance-attribute

npc_reactions: int

What to add to a monster or NPC reaction roll, which decides how a meeting starts.

max_retainers class-attribute instance-attribute

max_retainers: int = Field(ge=0)

How many hired followers the character may have at once.

retainer_loyalty class-attribute instance-attribute

retainer_loyalty: int = Field(ge=0)

The loyalty a retainer of this character starts with, rolled against when the retainer's nerve is tested.

ConstitutionRow

Bases: ScoreBand

One row of the constitution table. Constitution grants hit points and no other bonus.

hit_points instance-attribute

hit_points: int

What to add to every Hit Die rolled, whether at creation or on gaining a level.

A die never yields fewer than 1 hit point however negative this is.

DexterityRow

Bases: ScoreBand

One row of the dexterity table, with the three things dexterity grants.

ac instance-attribute

ac: int

What to add to armour class. A positive number here makes a descending armour class better, so it lowers it.

missile instance-attribute

missile: int

What to add to a missile attack roll. It doesn't touch missile damage.

initiative instance-attribute

initiative: int

What to add to an individual initiative roll.

Read only when the individual_initiative flag on Ruleset is on, since initiative is otherwise rolled once for a whole side.

IntelligenceRow

Bases: ScoreBand

One row of the intelligence table, covering language and literacy.

additional_languages class-attribute instance-attribute

additional_languages: int = Field(ge=0)

How many languages beyond the character's native ones they may choose at creation.

literacy instance-attribute

literacy: Literacy

How well the character reads and writes. See Literacy.

broken_speech class-attribute instance-attribute

broken_speech: bool = False

True only at intelligence 3, where the character speaks their native language brokenly.

Literacy

Bases: StrEnum

How well a character reads and writes, which their intelligence score sets.

Read it off AbilityTables.literacy, passing the character's intelligence score. It matters whenever the party finds something written, a scroll or a map or an inscription. No rule in osrlib reads it, so what a character who cannot read may not do is your game's decision.

ILLITERATE class-attribute instance-attribute

ILLITERATE = 'illiterate'

Cannot read or write, at intelligence 5 or below.

BASIC class-attribute instance-attribute

BASIC = 'basic'

Partial literacy, at intelligence 6 to 8: the step the SRD prints between illiterate and literate.

LITERATE class-attribute instance-attribute

LITERATE = 'literate'

Can read and write the character's native languages, at intelligence 9 or above.

OpenDoorsResult

Bases: BaseModel

How an attempt to force a door turned out, with the die kept so you can show it.

open_doors_check returns one.

roll instance-attribute

roll: int

What the d6 came up, from 1 to 6.

chance instance-attribute

chance: int

The chance in 6 that was rolled against.

success instance-attribute

success: bool

Whether the door opened, which it did when the roll came out at or under the chance.

PrimeRequisiteRow

Bases: ScoreBand

One row of the prime requisite table, which sets how fast a character earns experience.

A prime requisite is the ability a class is built around: wisdom for a cleric, strength for a fighter. This table applies to a class with a single prime requisite. A class with more than one has its own tiers on its class definition.

xp_modifier_pct instance-attribute

xp_modifier_pct: int

The percentage added to or taken off experience earned, from −20 at a score of 3 to +10 at 16 or above.

ScoreBand

Bases: BaseModel

The run of scores one table row covers, such as 4 to 5.

The SRD prints its modifier tables in bands rather than one row per score, and osrlib keeps them that way. Every row model below is a band with the row's own columns added. You'll meet these when you read a whole table off AbilityTables, for instance to draw the table on screen. To look up a single score, call an accessor instead and skip the rows entirely.

min_score class-attribute instance-attribute

min_score: int = Field(ge=MIN_SCORE, le=MAX_SCORE)

The lowest score this row covers.

max_score class-attribute instance-attribute

max_score: int = Field(ge=MIN_SCORE, le=MAX_SCORE)

The highest score this row covers. Equal to min_score on a one-score row.

StrengthRow

Bases: ScoreBand

One row of the strength table, with the two things strength grants.

melee instance-attribute

melee: int

What to add to a melee attack roll and to melee damage. Negative at a low score.

open_doors class-attribute instance-attribute

open_doors: int = Field(ge=0, le=6)

The chance in 6 of forcing a stuck door open.

Pass it to open_doors_check, which rolls the die against it.

WisdomRow

Bases: ScoreBand

One row of the wisdom table. Wisdom grants a saving throw modifier and no other bonus.

magic_saves instance-attribute

magic_saves: int

What to add to a saving throw against a magical effect. Negative at a low score.

ability_check

ability_check(score: int, stream: RngStream, modifier: int = 0) -> AbilityCheckResult

Roll an ability check: 1d20, equal-or-under the score succeeds.

Use this for a task the rules don't cover with a procedure of their own: shoving a boulder, spotting a change in the stonework, holding a rope. Pick the ability that fits and set the difficulty with modifier. For the things the rules do cover, call the function that covers them: open_doors_check for a stuck door, the saving throws in osrlib.core.combat for magic and traps, and the thief skills on the class definition for a thief's own work.

The roll is a d20, and the check succeeds on a modified roll at or under the score, so a higher score succeeds more often. The SRD leaves the difficulty to the referee and gives two numbers for it: "a –4 modifier for an easy task or +4 for a difficult task". A natural 1 always succeeds and a natural 20 always fails, which is the opposite way round from an attack roll.

Parameters:

Name Type Description Default
score int

The ability score to check against, from 3 to 18.

required
stream RngStream

The stream the d20 draws from.

required
modifier int

The difficulty, added to the roll. Positive makes the check harder.

0

Returns:

Type Description
AbilityCheckResult

The outcome, with the raw d20 kept for display.

Raises:

Type Description
ValueError

If score is outside 3 to 18.

Examples:

from osrlib.core.abilities import ability_check
from osrlib.core.rng import RngStreams

stream = RngStreams(master_seed=3).get("exploration")

# An 18 against a score of 13 fails, as a natural 20 would have.
check = ability_check(13, stream)
assert (check.roll, check.success) == (18, False)

# A difficult task: +4 on the roll, so a 5 is still under 13.
harder = ability_check(13, stream, modifier=4)
assert (harder.roll, harder.success) == (5, True)

apply_adjustment

apply_adjustment(
    scores: dict[AbilityScore, int],
    adjustment: AbilityAdjustment,
    prime_requisites: tuple[AbilityScore, ...],
    may_not_lower: tuple[AbilityScore, ...] = (),
) -> dict[AbilityScore, int]

Carry out a points trade and return the adjusted scores.

Call validate_adjustment first and pass the same arguments on. This function validates again and refuses rather than produce a score set the rules forbid, so an exception here means your code let an illegal trade through, not that the player chose badly. It either applies the whole trade or changes nothing.

Feed the result to create_character as the scores to build the character from.

Parameters:

Name Type Description Default
scores dict[AbilityScore, int]

The rolled scores. Left as they are, because the adjusted set comes back as a new dictionary.

required
adjustment AbilityAdjustment

The trade to carry out.

required
prime_requisites tuple[AbilityScore, ...]

The chosen class's prime requisites.

required
may_not_lower tuple[AbilityScore, ...]

Abilities this class forbids lowering.

()

Returns:

Type Description
dict[AbilityScore, int]

A new score dictionary with the points moved. All six abilities are present, and

dict[AbilityScore, int]

the ones the trade didn't touch keep their rolled values.

Raises:

Type Description
ValueError

If the trade breaks any rule validate_adjustment checks. The message names the rejection codes.

Examples:

from osrlib.core.abilities import AbilityAdjustment, AbilityScore, apply_adjustment

scores = {
    AbilityScore.STR: 9,
    AbilityScore.INT: 13,
    AbilityScore.WIS: 12,
    AbilityScore.DEX: 11,
    AbilityScore.CON: 14,
    AbilityScore.CHA: 10,
}
adjustment = AbilityAdjustment(
    lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2},
    raised={AbilityScore.STR: 2},
)

adjusted = apply_adjustment(scores, adjustment, (AbilityScore.STR,))
assert adjusted[AbilityScore.STR] == 11
assert adjusted[AbilityScore.INT] == 11
assert adjusted[AbilityScore.CON] == 14

# The rolled scores are untouched.
assert scores[AbilityScore.STR] == 9

open_doors_check

open_doors_check(chance: int, stream: RngStream) -> OpenDoorsResult

Roll one character's attempt to force a stuck door open.

Get the chance from AbilityTables.open_doors_chance for the character's strength. A d6 at or under the chance opens the door. The function rolls and reports, and nothing else: opening the door, and whatever the attempt costs the party, are yours to apply.

In a running game ForceDoor applies those consequences for you. It marks the party as having made noise, opens the door on a success and springs any trap rigged to it, and on a failure alerts the area beyond, all with the events to match. Call this function when you're working outside a session.

Parameters:

Name Type Description Default
chance int

The chance in 6, from 0 to 6.

required
stream RngStream

The stream the d6 draws from.

required

Returns:

Type Description
OpenDoorsResult

The outcome, with the raw d6 kept for display.

Raises:

Type Description
ValueError

If chance is outside 0 to 6.

Examples:

from osrlib.core.abilities import open_doors_check
from osrlib.core.rng import RngStreams
from osrlib.data import load_ability_tables

chance = load_ability_tables().open_doors_chance(16)
assert chance == 4

result = open_doors_check(chance, RngStreams(master_seed=3).get("exploration"))
assert (result.roll, result.success) == (5, False)

validate_adjustment

validate_adjustment(
    scores: dict[AbilityScore, int],
    adjustment: AbilityAdjustment,
    prime_requisites: tuple[AbilityScore, ...],
    may_not_lower: tuple[AbilityScore, ...] = (),
) -> list[Rejection]

Check a proposed points trade against the rules, and say what is wrong with it.

Call this before apply_adjustment and show the player what came back. An empty list means the trade is legal. The two functions take the same four arguments, so you can pass the same ones straight on.

The rules it enforces, from the SRD's third character-creation step and osrlib's readings of it in the adaptations register, the page that lists every place osrlib commits to one reading of the rules:

  • Only strength, intelligence, and wisdom may be lowered.
  • A prime requisite of the chosen class may not be lowered, and neither may an ability the class forbids lowering, which is why a thief may not lower strength.
  • Each score comes down by an even amount, since the two-for-one trade is worked out per score and an odd amount would strand half a point.
  • The points bought add up to half the points sold, and go only into the class's prime requisites.
  • No score is lowered below ADJUSTMENT_FLOOR or raised above MAX_SCORE.

Parameters:

Name Type Description Default
scores dict[AbilityScore, int]

The rolled scores, with all six abilities present.

required
adjustment AbilityAdjustment

The trade the player proposed.

required
prime_requisites tuple[AbilityScore, ...]

The chosen class's prime requisites, from ClassDefinition.

required
may_not_lower tuple[AbilityScore, ...]

Abilities this class forbids lowering, also from the class definition.

()

Returns:

Type Description
list[Rejection]

One Rejection per rule broken, empty when the

list[Rejection]

trade is legal. A single proposal can break several rules at once, so expect more

list[Rejection]

than one.

Examples:

from osrlib.core.abilities import AbilityAdjustment, AbilityScore, validate_adjustment

scores = {
    AbilityScore.STR: 9,
    AbilityScore.INT: 13,
    AbilityScore.WIS: 12,
    AbilityScore.DEX: 11,
    AbilityScore.CON: 14,
    AbilityScore.CHA: 10,
}

# Four points down, two points up, into the fighter's prime requisite.
legal = AbilityAdjustment(
    lowered={AbilityScore.INT: 2, AbilityScore.WIS: 2},
    raised={AbilityScore.STR: 2},
)
assert validate_adjustment(scores, legal, (AbilityScore.STR,)) == []

# Buying two points for two is not the rate.
greedy = AbilityAdjustment(lowered={AbilityScore.INT: 2}, raised={AbilityScore.STR: 2})
assert [rejection.code for rejection in validate_adjustment(scores, greedy, (AbilityScore.STR,))] == [
    "creation.adjustment.points_mismatch"
]