Skip to content
snrochePublic

About

Async FastAPI backend & deterministic rules engine in Python with schema-validated LLM orchestration amd incremental context management.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Sylvanna, a multiplayer D&D 5e server with an AI Dungeon Master

The LLM only narrates. The server handles every die roll, resource, turn order check and rule validation. The model writes prose around results it cannot change, and the client cannot change them either.

screenshot

Try it in 30 seconds

You don't need configuration, an API key or a database. Clone the repo, install and run. The full rules engine plays offline against a built-in dummy narrator. Add an LLM key later if you want prose.

pip install -r requirements.txt && uvicorn main:app --reload
  1. Open http://localhost:8000, click Fundar campaña, and enter a campaign name and your player name. You get an invite code, which you share with other players, and a player key, which you keep so you can rejoin.
  2. Create a character by choosing race, class and attributes. The server computes HP, AC, spell slots and your starting kit. The client only shows a preview.
  3. To roll, type an action in the composer, or click Tirada de habilidad and pick a skill and a DC. The result appears immediately as an ink stamp (d20 16+4 = 20 vs CD 12 → ÉXITO), and the DM's prose follows a moment later.

The interface and identifiers are in Spanish because the group this was built for plays in Spanish. This README is in English.

Sylvanna is a multiplayer tabletop RPG service. It has a D&D 5e rules engine with no third-party dependencies, an async FastAPI backend, and a vanilla JS frontend ("La Mesa") with no build step, served from the same process. Players join with an invite code, create characters, explore and fight while an LLM plays the Dungeon Master.

Rules engine5e SRD 5.1, levels 1–20, 0 third-party deps
BackendFastAPI · SQLAlchemy 2.0 async · Pydantic Settings
StorageSQLite (dev) or Postgres on Neon (prod), same code path
NarratorOpenRouter, any model, with automatic fallbacks
FrontendVanilla JS/CSS, no build step, works on mobile
Content336 monsters · 319 spells · 237 magic items after one import step
Tests225 tests, ~12 s, no network required

Why this design

If you run a tabletop game by telling an LLM "you are a DM, track the HP", the model forgets damage, invents spell slots, lets players act out of turn, and sides with whoever argues hardest. In Sylvanna the model has no write access to that state, so it can't make those mistakes.

Who owns what

The server owns The LLM owns
Dice, attack/damage resolution, saving throws Prose, scene description, NPC voice
HP, spell slots, class resources, conditions Requesting a check (pedir_tirada)
Initiative and turn validation Declaring world effects via a JSON contract
Inventory, gold, XP, level-ups Nothing that touches a character sheet

The narrator returns prose plus an optional JSON block of world effects, such as creating an NPC, opening a quest, granting loot, starting combat or applying a condition. The server checks every field against a whitelist and clamps its value. Narrative XP is capped at 50 × level, conditions last at most 3 rounds, and the server rejects purchases the character can't afford. It drops anything outside the schema without telling the model. If the model hallucinates, the story gets worse but the game state stays valid.

Every roll is auditable. The server stores each roll in the rolls table with its full resolution dict: the d20, the modifiers, the DC and the outcome.

Narration runs after the result. Actions return the mechanical result in about 25 ms, and the LLM narration runs in a BackgroundTask. The UI shows "the DM is narrating…" and polls for the text every 1.3 s in combat and every 5 s outside it. If OpenRouter fails, the server has already applied the mechanics and posts a system message with the cause, such as the daily free-tier limit, an overloaded model or an invalid key.


Running it

The three steps above are the whole setup. Without environment variables the app uses a local SQLite file and a deterministic dummy narrator, and every rule, roll and combat works offline. /docs serves the interactive API.

To enable the LLM narrator, copy .env.example to .env and set OPENROUTER_API_KEY.

pip install -r requirements-dev.txt
python -m pytest tests/ -q        # 225 tests

The combat and dice suites roll real dice instead of mocking them, so a run sometimes skips a case when the RNG made it untestable. Those guards call pytest.skip("azar"). A few scenarios can also fail when combat ends early. See the flaky-test item in the roadmap.


Architecture

engine/          Pure 5e engine, no dependencies, no I/O, fully unit-tested
  dice.py          d20 with advantage/disadvantage, damage dice, crits
  rules.py         modifiers, proficiency, DCs, progression tables 1–20
  combat.py        initiative, turn order, attack/save resolution
  entities.py      combatant value objects
  content.py       SRD content: races, classes, spells, monsters, items, feats
app/
  config.py        pydantic-settings, with a default for every variable
  db.py            async SQLAlchemy; SQLite dev / Neon prod + idempotent migrations
  models.py        Campaign, Player, Character, NPC, Quest, PendingConsequence,
                   Message, Roll
  hydration.py     converts DB rows to engine entities and back
  context.py       prompt assembly under a ~6–8k token budget
  narrator.py      OpenRouter client: retries, backoff, model fallback, dummy mode
  effects.py       validates and applies the LLM's JSON world-effect contract
  routers/         campaigns · characters · actions · admin
main.py          FastAPI app + static frontend mounted at /
static/          "La Mesa", the vanilla JS client, no build step
data/            SRD content imported from Open5e (JSON, not tracked in git)
scripts/         SRD importer, campaign admin CLI

The engine doesn't import the database layer. It takes value objects and returns resolution dicts. hydration.py is the only module that converts between database rows and engine objects, so the engine tests need no fixtures or mocks.

Context assembly. Each narrator call gets a rolling summary, the party, the NPCs in the scene with full stat blocks, active quests, pending consequences and the last 12 messages. Quests, NPCs and consequences live in database tables and never depend on the summary, so a wrong summary can't corrupt game state. The summary is incremental. Every RESUMIR_CADA messages, a cheaper model merges the new batch into the existing text, which adds one call per ~40 actions.


API tour

All game actions are POST /api/campaigns/{id}/… requests and authenticate with an X-Player-Token header.

POST /api/campaigns              { nombre, nombre_jugador }   → invite_code + player_token
POST /api/campaigns/join         { invite_code, nombre_jugador }
POST /api/characters             → server computes HP, AC, slots, starting kit
GET  /api/characters/{id}/sheet  → full live sheet
POST /api/campaigns/{id}/say     { texto }                    → narrative action
POST /api/campaigns/{id}/skill-check   { habilidad, cd }      → server-resolved roll
POST /api/campaigns/{id}/combat/start  { monstruos: [...] }   → initiative
POST /api/campaigns/{id}/combat/attack { objetivo_npc_id }    → your turn only
GET  /api/campaigns/{id}/messages?since=0                     → adaptive polling
GET  /api/campaigns/{id}/state                                → quests, NPCs, omens

There are about 50 endpoints in total, covering rests, inventory, spell management, rituals, level-up, wild shape, metamagic, attunement and mounts. GET /api/health/narrator makes a minimal real LLM call and reports the exact failure cause, which helps when you need to diagnose a deploy without shell access.


Rules coverage

The engine resolves everything below. None of it depends on the narrator.

Character creation and progression
  • 9 races and 30+ subraces, including all 10 draconic ancestries and 9 infernal tiefling lineages, with elemental resistances that halve damage.
  • 27-point buy, validated against the standard cost table, or free assignment.
  • 13 backgrounds that grant proficiencies, gear, gold and a roleplay trait.
  • Levels 1–20 with slot tables for full, half and pact casters, XP thresholds, spells known and resource uses. Extra Attack scales to 3 and 4 attacks.
  • Ability score improvements at levels 4, 8, 12, 16 and 19, or a feat instead. About a dozen feats have mechanical effects.
  • Multiclassing with ability prerequisites and combined slot tables.
  • Subclass selection at the correct level for each class.
  • You can add custom races and subraces in data/razas_custom.json without changing code.
Class features

Rage · Unarmored Defense · fighting styles (all six) · Second Wind · Action Surge · Sneak Attack · Martial Arts · Ki & Stunning Strike · Channel Divinity (Preserve Life, Turn Undead) · Lay on Hands · Divine Smite · Bardic Inspiration (scaling to d8) · Arcane Recovery · Wild Shape with beast stat blocks and CR caps · Sorcery Points, Font of Magic and Metamagic (Empowered, Heightened) · Eldritch Invocations (Agonizing Blast) · Expertise · Reckless Attack.

Combat

Initiative and strict turn validation · Attack, Dodge, Help, Shove, Flee · two-weapon fighting · opportunity attacks · critical hits · conditions with round durations (restrained, stunned, paralyzed with auto-crit, grappled, prone, frightened) · damage resistances · concentration · death saves and Medicine stabilization · a 120 s turn timeout that takes the Dodge action, so one AFK player can't stall the table · automatic XP by CR, split among survivors.

Magic and items

Slot-accurate casting, where the engine spends the slot before resolving the spell, so a failed spell still costs it · attack, save, healing and automatic resolution derived per spell · area and multi-target spells rolled once and split · upcasting for damage, healing and magic missile · ritual casting without slots · costly material components consumed from inventory · cantrip scaling at level 5 · prepared and known spell counts enforced per class · attunement (max 3) · consumables, equipping, trading and a gold economy where the server rejects unaffordable purchases · mounts with their own HP, AC and speed.

Human DM panel

An optional admin panel, disabled unless ADMIN_TOKEN is set. From it you can speak as the DM so the AI continues from your text, edit HP, gold and XP, clear conditions, manage NPCs and quests, rewrite the chronicle the AI uses as memory, and force-end a stuck combat. The server only accepts whitelisted fields, so the token can change the story but can't write arbitrary data to a character sheet.


Frontend ("La Mesa")

The frontend is vanilla JS with no bundler, framework or build step. The server serves it at the site root.

  • Sessions survive reloads through localStorage. To rejoin from another device, copy your player token from the header.
  • Mechanical results render as ink stamps (d20 14+5=19 vs AC 15 → HIT), set apart from the DM's prose.
  • The composer switches to combat mode on its own. It shows an initiative bar, highlights the current turn, and describes each target's health in words (unhurt, wounded, badly wounded) instead of exact HP.
  • Collapsible panels show the live sheet, quests, known NPCs with their attitudes, pending consequences ("omens") and the chronicle. Each panel remembers whether it was open.
  • Party chat. The DM reads it but never speaks for a player.
  • Regenerate (↺) and undo (⌫) for the last exchange. Both are disabled when the exchange involved dice or combat, so nobody can re-roll.
  • Tabs on mobile (Game, Sheet, World), and tolerance for server cold starts.

Deployment

Sylvanna runs as a single web service. The API serves the frontend, so you don't need CORS.

Setting Value
Build pip install -r requirements.txt && python scripts/importar_srd.py
Start uvicorn main:app --host 0.0.0.0 --port $PORT
Env OPENROUTER_API_KEY, OPENROUTER_MODEL, DATABASE_URL, ADMIN_TOKEN

The app creates its tables on first boot. Schema changes ship as an idempotent MIGRACIONES list, so existing campaigns keep working across deploys. You can paste the Neon connection string unchanged. The config layer switches the driver to asyncpg and strips sslmode.

The OpenRouter free tier allows 20 requests per minute and 50 per day, and failed attempts count toward both limits. Expect about one request per game action, plus one summary request per ~60 messages.

Content import (required for the full catalog)

This repository doesn't track game content. data/*.json is git-ignored, so a fresh clone has only the hand-curated content in engine/content.py: 15 monsters, 31 spells and 0 magic items. The app runs and every test passes with that content, but the bestiary is small.

One command downloads the full SRD catalog from Open5e. It needs internet access and takes about a minute.

python scripts/importar_srd.py                        # monsters ≤ CR 10, spells ≤ lvl 5
python scripts/importar_srd.py --cr-max 5 --solo monstruos

That writes data/monstruos_srd.json, data/conjuros_srd.json and data/objetos_magicos_srd.json, which engine/content.py merges at boot without overwriting the curated entries. The result is 336 monsters, 319 spells and 237 magic items. Monsters include full mechanics: HP, AC, abilities and the stat-block attacks the engine uses on NPC turns. The importer guesses each spell's resolution from its text, and spells it can't parse stay narrative.

Important

Because the JSON isn't committed, run the importer in your build step or copy the files to the server separately. Otherwise production boots with the 15-monster baseline.

Campaign admin

python scripts/admin.py list
python scripts/admin.py delete 3 7
DATABASE_URL="postgresql://..." python scripts/admin.py wipe --yes

Engineering notes

Some problems and how they were fixed.

  • Truncated JSON in the chat. The parser wasn't the cause. The model ran out of tokens in the middle of the JSON block and left an unclosed fence. The fix has three parts. The parser cuts off unclosed blocks, the token budget is higher, and the prompt tells the model to shorten the narration, never the JSON, when it runs short of space.
  • Windows encoding. The importer writes UTF-8 explicitly, and the loader also reads files previously written as cp1252, so existing data still loads.
  • One source of truth for content. The frontend used to keep its own copy of the race list. Now GET /api/content serves the full catalog and the UI renders it.
  • Backwards compatibility. Alias keys keep old characters loading after content renames, and the one added column ships through the idempotent migration list.

Roadmap

  • Rules engine + test suite
  • Models + deployable REST API
  • Narrator pipeline: rolling summary, JSON contract, spells, XP by CR
  • Full frontend served from /
  • Levels 1–20, feats, backgrounds, multiclassing, magic items
  • Per-subclass mechanics. The DM narrates subclasses today, but the engine doesn't enforce them.
  • Seed the RNG in combat tests. test_combate_ronda.py::test_descripcion_de_ataque_queda_en_el_log fails on roughly 5% of runs. Three goblins can drop the level-1 fighter before their turn, combat ends, and the test indexes a None state. The neighbouring tests guard this with pytest.skip("azar"), and this one doesn't.
  • WebSockets. Adaptive polling has been enough for small groups so far.

License and attribution

Code. MIT, see LICENSE. The license covers everything in engine/, app/, static/, scripts/, tests/ and main.py.

SRD content. This work includes material taken from the System Reference Document 5.1 ("SRD 5.1") by Wizards of the Coast LLC and available at https://dnd.wizards.com/resources/systems-reference-document. The SRD 5.1 is licensed under the Creative Commons Attribution 4.0 International License, available at https://creativecommons.org/licenses/by/4.0/legalcode.

Imported data. The importer pulls monsters, spells and magic items into data/ from the Open5e API, filtered to the wotc-srd document slug. That data carries the SRD 5.1 licence above. Open5e is an open database of 5e content, and thanks go to its maintainers. This repository doesn't redistribute the data. data/*.json is git-ignored and generated locally by scripts/importar_srd.py.

Sylvanna is an unofficial fan project, not affiliated with, endorsed, sponsored or approved by Wizards of the Coast.

A few options outside the SRD (12 backgrounds, 12 feats and 9 tiefling lineages) ship as mechanical data only. That means ability adjustments, proficiency lists and level tables, with original descriptions instead of rulebook text. TODO(no-SRD) comments mark them in engine/content.py. For anything else, data/razas_custom.json is a git-ignored extension point. Add content from books you own there, locally, instead of committing it.


Sylvanna started as an experiment in giving an LLM the full story as context and no write access to game state.

About

Async FastAPI backend & deterministic rules engine in Python with schema-validated LLM orchestration amd incremental context management.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages