Quickstart
Install:
pip install dnd5e-engine
dnd5e-srd-data comes along as a dependency — the engine reads its rules
content from it and performs no I/O of its own. To drive the engine from your
own typed corpus instead, install a loader with configure_lib_loader.
A grid combat in ~20 lines
This runnable example opens a combat on a 10×10 grid, moves a hero one step,
and closes the encounter — using only names from dnd5e_engine.__all__:
"""Run a grid combat end-to-end with the public dnd5e-engine API.
A lone Hero faces a Goblin Warrior on a 10x10 grid. The Hero steps into reach,
swings a longsword, and the goblin answers — then we close the encounter. Every
name used here comes from ``dnd5e_engine.__all__`` (the public surface).
Two things worth noticing:
* ``rng_seed`` makes the whole combat reproducible. Run this twice and you get
byte-identical output.
* ``narration_events`` is consumed concurrently — it streams until
``end_combat`` closes it.
* A ``"move"`` intent steps to an **adjacent** cell only — the engine does not
path-find, so closing two cells takes two intents.
"""
from __future__ import annotations
import asyncio
from dnd5e_engine import (
CombatEvent,
EncounterMemberSpec,
GridScene,
PartyMemberSpec,
PlayerIntent,
advance_monster_turn,
cell_id,
end_combat,
narration_events,
start_combat,
submit_player_intent,
)
def describe(event: CombatEvent) -> str:
"""Render one typed event as a line of text.
The engine decides *what happened*; turning that into prose is the host's
job. This is the smallest possible version of that job.
"""
match event.type:
case "attack_rolled":
outcome = "CRIT" if event.is_crit else "hit" if event.is_hit else "miss"
return f" {event.attacker_id} attacks {event.target_id}: {event.roll_total} ({outcome})"
case "damage_applied":
return f" {event.target_id} takes {event.amount} {event.damage_type} damage"
case "death":
return f" {event.entity_id} drops!"
case "move_failed":
return f" move rejected: {event.reason}"
case "actor_moved":
return f" {event.actor_id} moves {event.from_zone} -> {event.to_zone}"
case _:
return f" [{event.type}]"
async def main() -> None:
# Hero at (0,0); a real SRD goblin two cells away at (2,0).
start = await start_combat(
session_id="example",
party=[
PartyMemberSpec(
entity_id="char:hero",
name="Hero",
initiative=20, # high initiative => the Hero acts first
hp_current=12,
hp_max=12,
ac=12,
zone_id=cell_id(0, 0),
)
],
encounter=[
EncounterMemberSpec(
entity_id="mon:goblin",
entity_type="Monster",
name="Goblin Warrior",
# Resolved from the bundled SRD corpus: gives the goblin its real
# Scimitar/Shortbow actions instead of the generic fallback.
monster_template_slug="goblin-warrior",
initiative=1,
hp_current=7,
hp_max=7,
zone_id=cell_id(2, 0),
)
],
grid_scene=GridScene(width=10, height=10),
rng_seed=1,
)
handle = start.handle
# ``narration_events`` streams until ``end_combat`` closes the queue, so a
# host consumes it concurrently with driving the combat. That is the real
# integration shape: the engine decides what happens, you render it.
async def narrate() -> None:
async for event in narration_events(handle):
print(describe(event))
narrator = asyncio.create_task(narrate())
print("Round 1 — Hero closes and attacks:")
# One step per intent: (0,0) -> (1,0) puts the Hero in longsword reach.
await submit_player_intent(
handle,
actor_id="char:hero",
intent=PlayerIntent(intent_type="move", target_zone_id=cell_id(1, 0)),
)
await submit_player_intent(
handle,
actor_id="char:hero",
intent=PlayerIntent(
intent_type="attack", target_id="mon:goblin", weapon_id="longsword"
),
)
await advance_monster_turn(handle)
result = await end_combat(handle)
await narrator # end_combat closes the stream, so this now returns
print(f"\nCombat ended ({result.outcome.ended_reason}).")
print(f"Residual HP: {result.outcome.residual_hp}")
if __name__ == "__main__":
asyncio.run(main())
The combat loop is four public coroutines:
start_combat(...)— open the encounter, returns aStartCombatResultcarrying theCombatHandleyou thread through every later call.submit_player_intent(handle, actor_id, intent)— resolve one PC turn.advance_monster_turn(handle)— let a monster take its turn.end_combat(handle)— close out, returning anEndCombatResultwith the projectedCombatOutcome.
A one-shot skill check
For an out-of-combat ability, skill, or saving-throw roll, resolve_check
takes a CheckSpec and returns a CheckResult. Pass a seeded
CheckSpec.rng to make the roll reproducible — unlike combat, which is seeded
once via start_combat(rng_seed=...), a standalone check carries its own
generator:
"""Resolve a single skill check with the public dnd5e-engine API.
``resolve_check`` reads only the ``CheckSpec`` you hand it — no I/O, no combat
handle. Pass a seeded ``CheckSpec.rng`` and the roll is reproducible; leave it
``None`` and the d20 comes from the process-global ``random`` module instead.
"""
from __future__ import annotations
import random
from dnd5e_engine import CheckSpec, resolve_check
# A proficient Rogue (Dex 16, +2 proficiency) attempts a DC 15 Stealth check.
spec = CheckSpec(
kind="skill",
skill="stealth",
ability_scores={"strength": 10, "dexterity": 16, "constitution": 12,
"intelligence": 10, "wisdom": 12, "charisma": 14},
proficient_skills=("stealth",),
proficient_saves=(),
proficiency_bonus=2,
dc=15,
# Seeded generator => the same roll every run, regardless of what else in
# the process has touched ``random``.
rng=random.Random(42),
)
result = resolve_check(spec)
verdict = "SUCCESS" if result.success else "FAILURE"
print(f"Stealth (d20={result.natural_roll}) total {result.roll_total} "
f"vs DC {result.dc}: {verdict}")
Building a combat-ready character
make_build_spec constructs a CharacterBuildSpec; build_party_member
resolves it against the SRD 5.2 corpus into a PartyMemberSpec:
"""Resolve a CharacterBuildSpec into a combat-ready PartyMemberSpec.
``make_build_spec`` constructs the character contract (species/class/level/
abilities); ``build_party_member`` resolves it against the bundled SRD 5.2
corpus (loaded via ``BundledAssetLoader``) plus a ``CombatInstance`` carrying
the rolled combat stats (hp/ac/initiative/position).
"""
from __future__ import annotations
from dnd5e_srd_data import BundledAssetLoader
from dnd5e_engine import (
CombatInstance,
build_party_member,
cell_id,
make_build_spec,
)
# The character contract: a level-1 human Fighter with a classic stat array.
build_spec = make_build_spec(
species_slug="human",
class_slug="fighter",
level=1,
ability_scores={"str": 16, "dex": 12, "con": 14,
"int": 10, "wis": 12, "cha": 8},
)
# Combat-instance values that are not character-derived (rolled HP, AC, start cell).
instance = CombatInstance(
entity_id="char:valeros",
name="Valeros",
hp_current=12,
hp_max=12,
ac=16,
initiative=2,
zone_id=cell_id(0, 0),
)
member = build_party_member(build_spec, instance, loader=BundledAssetLoader())
print(f"{member.name}: level {member.character_level} "
f"{member.species_slug} {member.class_slug}")
print(f" HP {member.hp_current}/{member.hp_max} AC {member.ac} "
f"speed {member.base_speed}ft")
print(f" STR {member.strength} DEX {member.dexterity} CON {member.constitution} "
f"INT {member.intelligence} WIS {member.wisdom} CHA {member.charisma}")
Next: read the combat model, check the capability matrix to see which rules are actually resolved, or browse the full API reference.