Skip to content

osrlib.crawl.battle

Run a fight round by round on the range track, with a pluggable policy driving the monsters.

This module takes over once osrlib.crawl.encounter has decided there is a fight. start_battle is the entry point, and the encounter procedure calls it for you: from an attacking stance, from a hostile group's deadline arriving, from a chase that closed to arm's length, and from the party's own EngageBattle command. After it returns the session is in battle mode, session.battle holds a BattleState, and each round is one ResolveBattleRound command with one BattleDeclaration per living, able party member, dispatched through the session's private handler table. No command ends the battle. It ends from inside, when the party is wiped, when every monster group is dead or routed, or when the whole party retreats. A victory hands control straight to end_encounter.

The results reach a front end as events from osrlib.crawl.events: BattleStartedEvent, BattleRoundEvent opening each round, SpellDeclaredEvent for every cast declared that round, GroupMovedEvent as the range track changes, MonsterFledEvent when a side breaks, MonstersLeftBehindEvent for the helpless a fleeing side abandons, and BattleEndedEvent reporting victory, defeat, or flight. The kernel's own attack, damage, saving throw, initiative, and morale events come back interleaved with those.

A round wraps the kernel in the OSE SRD's sequence: declaration, initiative, then per side morale, movement, missiles, magic, and melee, with slow-weapon actors last. Every resolution step is a function in osrlib.core.combat or osrlib.core.spells, among them roll_initiative, resolve_attack, resolve_breath, morale_triggers, and cast_spell. What this module adds to them is the ordering, the state it hands each of them (the RNG streams, the effects ledger, the clock, the entity registry), the range track the kernel has no notion of, and the party's formation. Call those kernel functions directly when you want one resolution and no session at all. The guide on using the rules without a session walks that path.

The combat space is the abstract per-group range track, the Bard's Tale convention, as a documented adaptation (see the adaptations register, the page listing where osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset flag). Each monster group sits at a distance from the party, closes at its encounter rate, and fights at MELEE_RANGE_FEET. Party ranks derive from marching order under the ruleset's formation_width_limit flag, and the width is the frontage the party's own space offers at FIGHTER_FRONTAGE_FEET to a combatant: two abreast in a ten-foot passage, and a room's shorter side in a room.

The machine detects spell disruption, meaning a declared caster who is successfully attacked or fails a save after initiative resolves against them and before their own action. It checks morale on its own, with no command for it. It ends each single-use protection as it is spent. Invisibility breaks when the member attacks, throws or unleashes an item, or turns undead, and the kernel's cast_spell is what breaks it on a cast. An incoming attack on a target under mirror image pops one figment instead, hit or miss, because no attack roll is made at all. A protection ward breaks when the party melees a monster it barred. A concentration spell's effects release when its caster declares anything other than cast, turn_undead, or hold. Area footprints resolve deterministically: an area's capacity in creatures is ceil(span / 10) × width, filled in stable spawn order, cones reach-limited, with the engaged party front rank appended under the ruleset's aoe_friendly_fire flag.

The monster and NPC-party sides act through a pluggable ActionPolicy you can substitute per encounter side. ScriptedPolicy and NpcPartyPolicy ship as the defaults, and both draw only from the monster_action stream, so a policy of your own never shifts an attack or damage draw. Initiative still resolves in side blocks, because the SRD's phase sequence runs per side rather than per combatant, and the sides order by their best individual total.

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.adventure import Adventure, TownSpec
from osrlib.crawl.commands import (
    BattleDeclaration,
    EngageBattle,
    EnterDungeon,
    ResolveBattleRound,
    SessionMode,
    SpawnMonsters,
)
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

rules = Ruleset()
draw = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
members = [
    create_character(
        name=name,
        class_id="fighter",
        alignment=Alignment.LAWFUL,
        ruleset=rules,
        stream=draw,
    ).character
    for name in ("Hild", "Osric")
]
level = LevelSpec(
    number=1,
    width=2,
    height=1,
    entrance=(0, 0),
    edges={"1,0:west": Edge(kind=EdgeKind.OPEN)},
)
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=members), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))

# Spawn a group already within reach, then choose to fight it.
session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=5))
session.execute(EngageBattle())
assert session.mode is SessionMode.BATTLE
assert session.battle.round == 0

# One command is one round: a declaration per living, able member, every group named by id.
group_id = session.encounter.groups[0].id
result = session.execute(
    ResolveBattleRound(
        declarations=tuple(
            BattleDeclaration(character_id=member.id, action="attack", target_group_id=group_id) for member in members
        )
    )
)
assert result.accepted
assert session.battle.round == 1
assert result.events[0].code == "battle.round.started"
assert "combat.initiative.rolled" in [event.code for event in result.events]

FIGHTER_FRONTAGE_FEET module-attribute

FIGHTER_FRONTAGE_FEET = 5

Feet of frontage one combatant needs in order to fight side by side with the next.

The party's front rank is as wide as its own fighting space divided by this, so a ten-foot passage holds two abreast and a room holds as many as its shorter side allows. A member outside the front rank is rejected for declaring a melee attack. The rank check does not apply to a missile attack, so the back ranks can still shoot.

RAW prints one number for this and leaves the rest to judgement: "The referee should judge the number of opponents that can attack a single combatant, bearing in mind the combatant's size and the available space around them. 10' passage: Enough space for at most 2-3 characters to fight side-by-side." osrlib takes the conservative end of that range, two in ten feet and so five feet each, and then applies it to whatever space the party is actually standing in.

To play without a width limit, turn off the ruleset's formation_width_limit flag, which puts every living member in the front rank. Changing this constant instead would move the frontage of every space in the game at once.

FLEE_EXIT_FEET module-attribute

FLEE_EXIT_FEET = 120

How far, in feet, a routed group runs before the battle lets it go.

A group that breaks morale turns and runs its full movement rate each round. Once its distance passes this, the group is marked fled: it takes no further action, an attack declared against it is rejected as naming an unknown group, and a battle in which every group has fled or died ends in victory. Inside that window the party can still chase it down or shoot it in the back, which is what the window is for. A group that is merely afraid rather than broken counts as routed on the same terms, once it too is past this distance.

MELEE_RANGE_FEET module-attribute

MELEE_RANGE_FEET = 5

The gap, in feet, at which a group on the range track is close enough to trade blows.

A group closing on the party stops here rather than at zero, a melee attack declared against a group further off than this is rejected, and a monster's melee resolves with this as the distance handed to the kernel. It is also where the monster groups stand when a chase collapses into a fight and the pursuers catch the party.

The abstract track has no distance between this and zero, so treat the value as fixed rather than as a setting: every reach and range check in the module reads it. To give a weapon longer reach, put the range data on the weapon, which is what validate_attack reads.

NPC_PARTY_MORALE module-attribute

NPC_PARTY_MORALE = 9

The morale score a group of NPC adventurers checks against, on the usual 2 to 12 scale.

Monsters have a morale score in their stat block, but adventurers in the OSE SRD do not, so osrlib uses the score printed for the Veteran, its own low-level adventurer monster, rather than inventing one. This is a documented adaptation (see the adaptations register, the page listing where osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset flag). A score of 9 holds on a 2d6 total of 9 or less once the situational modifier is added, and breaks above that.

The battle machinery reads this value directly, so every NPC adventurer group in the game checks against the same score and there is no per-group override.

ActionPolicy

Bases: Protocol

The monster side's brain: what decides, each round, what one monster group does.

Write a class with a choose method matching this protocol when you want tactics of your own, and register it for a group by its id on session.action_policies, a plain dict the session keeps and never serializes. A group with no entry there gets ScriptedPolicy, or NpcPartyPolicy when its members are NPC adventurers. Since policies are code rather than state, re-register yours after loading a save, the way you re-register listeners.

Every policy draws only from the monster_action stream, so a policy of your own never shifts an attack or damage roll and the rest of the fight stays reproducible from the same seed. Return whatever actions your tactics call for: the round handler skips one the situation no longer allows rather than raising. The shipped policies never cast, because monster casting is tagged for manual resolution in the data.

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.adventure import Adventure, TownSpec
from osrlib.crawl.battle import MonsterAction
from osrlib.crawl.commands import (
    BattleDeclaration,
    EngageBattle,
    EnterDungeon,
    ResolveBattleRound,
    SpawnMonsters,
)
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

rules = Ruleset()
draw = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hild = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=draw,
).character
level = LevelSpec(
    number=1,
    width=2,
    height=1,
    entrance=(0, 0),
    edges={"1,0:west": Edge(kind=EdgeKind.OPEN)},
)
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[hild]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))
session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30))
session.execute(EngageBattle())

# Monsters that hold their ground and never swing.
class StandStillPolicy:
    def choose(self, session, group, stream):
        return [MonsterAction(monster_id=monster_id, kind="hold") for monster_id in group.monster_ids]

# Register the policy for one group, by group id.
group = session.encounter.groups[0]
session.action_policies[group.id] = StandStillPolicy()

result = session.execute(
    ResolveBattleRound(
        declarations=(BattleDeclaration(character_id=hild.id, action="hold"),),
    )
)
assert result.accepted
assert group.distance_feet == 30  # the goblins never close
assert hild.current_hp == hild.max_hp  # and never swing

choose

choose(session, group, stream) -> list[MonsterAction]

Choose this round's actions for one monster group.

The round handler calls this once per group per round, after morale has resolved and before anything on that side moves or attacks.

Parameters:

Name Type Description Default
session GameSession

The running session. Read the party, the registry, and the effects ledger through it, and don't mutate it here.

required
group EncounterGroup

The group to act. Its distance_feet is the current gap and its monster_ids are its members, dead ones included.

required
stream RngStream

The monster_action stream. Take every random choice from it and from nothing else, or you break the determinism of the rest of the fight.

required

Returns:

Type Description
list[MonsterAction]

The actions to resolve, in the order they should resolve. Return an empty list for a group that does nothing. Skip the dead and the incapacitated: the handler ignores actions for them anyway.

BattleState

Bases: BaseModel

A battle in progress: the round counter and the per-round bookkeeping a fight needs to keep.

You get this from session.battle, which is None whenever no battle is running. It is an overlay on the encounter rather than a replacement for it: the monster groups, their distances, and their treasure stay on session.encounter, and this holds only what the fight itself has to remember. It serializes with the session, so a saved game resumes mid-battle. Read it and render from it. The round handler is what writes every field.

To change a fight while it runs, send a referee command from osrlib.crawl.commands rather than writing to this model. Every referee command is legal in battle mode, so a referee can grant an item, award experience, set a flag, or advance the clock mid-fight, and the change replays from a save because the command goes through the command log. SpawnMonsters and SpawnNpcParty are the exception: both reject while an encounter is open, so a fresh side cannot join a fight already underway.

round class-attribute instance-attribute

round: int = 0

Rounds resolved so far. It is 0 between start_battle and the first ResolveBattleRound, except that a monsters' free round counts as round 1.

started_round instance-attribute

started_round: int

The session clock's round count when the battle opened.

monsters_hold_rounds class-attribute instance-attribute

monsters_hold_rounds: int = 0

Rounds the monster side still owes to the party's surprise. A round in which this is above zero spends one and the monsters do nothing at all.

morale class-attribute instance-attribute

The kernel's MoraleTracker for this battle. It records each group's held checks, and a group that has held two stops checking.

morale_acted class-attribute instance-attribute

morale_acted: dict[str, list[str]] = {}

The morale triggers already spent, per group id. Each trigger fires one check per group per battle, so a side that has already checked on losing its leader does not check again for the same reason.

fired_last_round class-attribute instance-attribute

fired_last_round: list[str] = []

The ids of party members who loosed a missile last round, which is what the kernel's reload rule reads.

melee_engagements class-attribute instance-attribute

melee_engagements: dict[str, list[str]] = {}

Per party member id, the monsters that member has actually closed with. A monster barred from a warded character by protection from evil may attack them anyway once it is on this list, which is RAW's own clause.

concentration class-attribute instance-attribute

concentration: dict[str, list[str]] = {}

Per caster id, the effect ids a concentration spell is holding up. They release as soon as that caster declares anything other than cast, turn_undead, or hold.

MonsterAction

Bases: BaseModel

One monster's or NPC adventurer's chosen action for a round: what an action policy returns.

An ActionPolicy builds a list of these and the round handler resolves them in order. The model is frozen, so build a new one rather than editing one. Nothing validates a policy's output ahead of time: an action the situation does not allow, like a melee against a target that died earlier in the round, is skipped when the handler reaches it.

monster_id instance-attribute

monster_id: str

The acting combatant's entity id, which must be one of its own group's monster_ids.

kind instance-attribute

kind: str

What the combatant does. "close" advances the whole group one encounter rate toward the party and stops at MELEE_RANGE_FEET, and only the first such action in a round moves the group. "melee" attacks target_id. "breath" looses the monster's breath weapon over the party. "hold" does nothing. "npc_shoot", "npc_cast", and "npc_drink" are the NPC adventurer actions: a missile attack on target_id, a cast of spell_id, and a swallowed potion named by item_id.

target_id class-attribute instance-attribute

target_id: str | None = None

The target's entity id for "melee", "npc_shoot", and a single-target "npc_cast". None for an area spell, which takes its own targets from the footprint rule.

spell_id class-attribute instance-attribute

spell_id: str | None = None

The spell to cast, for "npc_cast".

spell_mode class-attribute instance-attribute

spell_mode: str | None = None

Which mode of that spell to use, for "npc_cast".

item_id class-attribute instance-attribute

item_id: str | None = None

The magic item instance id to use, for "npc_drink".

NpcPartyPolicy

The default ActionPolicy for a group of NPC adventurers.

A group whose members are NPC adventurers rather than monsters runs on one of these unless you registered something else for it on session.action_policies. The OSE SRD gives no tactics of its own for an opposing party of adventurers, so osrlib supplies these, as a documented adaptation (see the adaptations register, the page listing where osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset flag).

Each living member picks the first of these that applies. A caster holding a memorized healing spell heals the group's most wounded member below half hit points, taking the lowest ratio of current to maximum and breaking ties by id. A member below half hit points whose group has no healing spell left drinks a healing potion it carries, which is the only item use these tactics make. A caster holding a memorized attack spell that osrlib resolves without a referee casts it at the party, highest spell level first and ties broken by spell id, taking area targets through the footprint rule and a single target uniformly from the rank it can reach. A member with a missile weapon shoots while the gap is wider than MELEE_RANGE_FEET. Failing all of that, the member closes and melees, the same as a monster.

These casts post their declarations at the top of the round and are disruptable exactly as the party's are, because RAW's disruption trigger does not care which side declared. Every choice draws from the monster_action stream alone.

choose

choose(session, group, stream) -> list[MonsterAction]

Choose this round's actions for one group of NPC adventurers.

Parameters:

Name Type Description Default
session GameSession

The running session.

required
group EncounterGroup

The group to act.

required
stream RngStream

The monster_action stream.

required

Returns:

Type Description
list[MonsterAction]

One MonsterAction per living member that can act, in the group's own member order. Incapacitated and confused members get none.

ScriptedPolicy

The default ActionPolicy for monsters: breath, then close and melee.

Every monster group runs on one of these unless you registered something else for it on session.action_policies, so you construct one yourself only to wrap it.

Monsters whose data includes a scripted pattern follow it. A breath weapon with a daily use count opens with breath, then takes breath or melee with equal chance while uses remain, which is how the OSE SRD's dragons fight. A breath weapon gated on a chance in six rolls that gate each round, as the hellhound's does. Otherwise a group further off than MELEE_RANGE_FEET closes, and once at that range each monster picks its target uniformly from the party rank it can reach. Monster missile routines have no structured range data, so osrlib treats them the way it treats melee, closing first and then attacking, as a documented adaptation (see the adaptations register, the page listing where osrlib commits to one reading of an ambiguous rule or supplies a default behind a Ruleset flag). These groups never cast, because monster spell casting is tagged for manual resolution in the data.

Substitute a policy of your own when you want a side to hold a chokepoint, concentrate its attacks, back off, or cast. NpcPartyPolicy is the shipped example of a policy that does more than this one.

choose

choose(session, group, stream) -> list[MonsterAction]

Choose this round's actions for one monster group.

Parameters:

Name Type Description Default
session GameSession

The running session.

required
group EncounterGroup

The group to act.

required
stream RngStream

The monster_action stream.

required

Returns:

Type Description
list[MonsterAction]

One MonsterAction per living monster that can act, in the group's own member order. Incapacitated and confused monsters get none, because the round handler runs confusion itself.

start_battle

start_battle(session, *, party_free_round: bool = False, monsters_free_round: bool = False) -> list[Event]

Open battle on the session's current encounter: the range track takes over.

Call this when your own code decides the talking is over. There must be an open encounter on the session, because the fight runs on that encounter's groups and their distances. On return the session is in battle mode and session.battle holds a BattleState. From there, each round is one ResolveBattleRound command.

Use EngageBattle instead when the party is the one choosing to fight. That command goes through the session's command log, so the fight replays from a save, and it works out the surprise arguments and a mid-chase turn-and-fight for you. The encounter procedure calls this function itself for every other opening, so a front end that only issues commands never calls it at all.

A group whose morale score is 2 routs the moment battle starts, per RAW, which can end the battle before a round has run. A surprise advantage becomes one free round for the side that holds it: the monsters' free round resolves inside this call, with the party unable to answer, while the party's free round holds the monsters through the first ResolveBattleRound.

Parameters:

Name Type Description Default
session GameSession

The running session, with session.encounter set.

required
party_free_round bool

True when the monsters were surprised. The monster side then sits out the first round.

False
monsters_free_round bool

True when the party was surprised and the stance is hostile or attacking. The monsters act once inside this call, before the party's first declaration.

False

Returns:

Type Description
list[Event]

The opening events: BattleStartedEvent, a MonsterFledEvent for each group that routed on sight, whatever a monsters' free round resolved, and a BattleEndedEvent already when the opening itself left a terminal state.

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.adventure import Adventure, TownSpec
from osrlib.crawl.battle import start_battle
from osrlib.crawl.commands import EnterDungeon, SessionMode, SpawnMonsters
from osrlib.crawl.dungeon import DungeonSpec, Edge, EdgeKind, LevelSpec
from osrlib.crawl.party import Party
from osrlib.crawl.session import GameSession

rules = Ruleset()
draw = RngStreams(master_seed=7).get(CHARACTER_CREATION_STREAM)
hild = create_character(
    name="Hild",
    class_id="fighter",
    alignment=Alignment.LAWFUL,
    ruleset=rules,
    stream=draw,
).character
level = LevelSpec(
    number=1,
    width=2,
    height=1,
    entrance=(0, 0),
    edges={"1,0:west": Edge(kind=EdgeKind.OPEN)},
)
crypt = DungeonSpec(id="crypt", name="The Old Crypt", levels=(level,))
town = TownSpec(name="Threshold", travel_turns={"crypt": 1})
adventure = Adventure(name="A First Delve", town=town, dungeons=(crypt,))
session = GameSession.new(Party(members=[hild]), adventure, seed=7)
session.execute(EnterDungeon(dungeon_id="crypt"))
session.execute(SpawnMonsters(template_id="goblin", count_fixed=2, distance_feet=30))

# The party caught the goblins flat-footed, so it gets the first round free.
events = start_battle(session, party_free_round=True)
assert [event.code for event in events] == ["battle.started"]
assert session.mode is SessionMode.BATTLE
assert session.battle.round == 0
assert session.battle.monsters_hold_rounds == 1