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.
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- 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.
- 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.
- 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 engine | 5e SRD 5.1, levels 1–20, 0 third-party deps |
| Backend | FastAPI · SQLAlchemy 2.0 async · Pydantic Settings |
| Storage | SQLite (dev) or Postgres on Neon (prod), same code path |
| Narrator | OpenRouter, any model, with automatic fallbacks |
| Frontend | Vanilla JS/CSS, no build step, works on mobile |
| Content | 336 monsters · 319 spells · 237 magic items after one import step |
| Tests | 225 tests, ~12 s, no network required |
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.
| 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.
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 testsThe 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.
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.
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, omensThere 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.
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.jsonwithout 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.
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.
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.
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 monstruosThat 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.
python scripts/admin.py list
python scripts/admin.py delete 3 7
DATABASE_URL="postgresql://..." python scripts/admin.py wipe --yesSome 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/contentserves 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.
- 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_logfails on roughly 5% of runs. Three goblins can drop the level-1 fighter before their turn, combat ends, and the test indexes aNonestate. The neighbouring tests guard this withpytest.skip("azar"), and this one doesn't. - WebSockets. Adaptive polling has been enough for small groups so far.
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.
