Skip to content

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 a StartCombatResult carrying the CombatHandle you 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 an EndCombatResult with the projected CombatOutcome.

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.