A practical map of the codebase: the packages, how a turn flows end to end, the data model, and where each responsibility lives. For the why behind these choices, see DESIGN.md.
The engine is the source of truth. The LLM is the storyteller.
The LLM (the "DM") never edits numbers. It proposes actions by calling tools; the engine validates them, rolls dice (seeded), mutates state, and returns results; the LLM then narrates what the engine decided. Every design choice — the tool layer, the deterministic RNG, the snapshot model — follows from this. If you find yourself about to let the model set an HP value or decide a hit, stop: that belongs in the engine.
A monorepo of npm workspaces (packages/*). Dependencies point downward only:
web ─┐
├─→ protocol (shared wire types; types only, no runtime)
server ┘ ↘
└─→ engine (pure rules; no I/O, no LLM, no network)
| Package | Role | Depends on |
|---|---|---|
@ai-dm/engine |
Deterministic SRD 5.1 rules: dice, abilities, skills, conditions, damage, creatures, combat, grid, encounters. Pure, seeded, no I/O. Fully unit-tested in isolation. | — |
@ai-dm/protocol |
Shared DTOs + WebSocket message types between server and web. Types only (no runtime code), so both sides can't drift. | — |
@ai-dm/server |
Fastify app: REST API, the WebSocket play room, the DM agent loop + engine-backed tool layer, persistence, real-time/scaling, and pluggable DM + image providers. | engine, protocol |
@ai-dm/web |
Vite + React SPA: accounts, character creator/viewer, adventure create/list/resume, and the live play screen. | protocol |
Build order matters:
serverandwebconsumeenginevia its compileddist/. Build the engine first (npm run build --workspace @ai-dm/engine) before typechecking/testing the others. CI does this explicitly.
Play happens over a WebSocket. A single player action flows like this
(packages/server/src/index.ts):
- Client → server. The web client sends
{ type: "action", text }over/ws(web/src/useGame.ts). On connect it first sends{ type: "join", token, adventureId, characterId? }. handleAction(adventureId, userId, text)acquires a per-adventure lock (runtime.lock), so actions are processed strictly one at a time — even across multiple server instances (the lock is a RedisSET NX; in-process otherwise).runtime.loadSession(adventureId)materializes the liveSession(see Live state & scaling below).processTurn(session, userId, text):- appends the player's line to the log and broadcasts
dmThinking: true; - builds the tool layer with
createTools(session, images, store)(gameEngine.ts); - calls
dm.respond({ session, playerId, text, tools })— the DM provider runs its tool-calling loop, the engine resolves every mechanical action, and the DM returns narration; - appends the narration, refreshes situation-aware
suggestions, folds older turns into the rolling recap when due (updateMemory, see DESIGN.md §5), persists withruntime.saveSession, and broadcasts the new snapshot.
- appends the player's line to the log and broadcasts
- Server → all clients. Snapshots fan out to every connected socket (locally,
and to other instances via Redis pub/sub). Each client renders
SessionView.
REST requests (auth, SRD catalog, characters, adventures, portraits) are separate
and conventional — see routes.ts.
A live Session (session.ts) is the in-memory game: creatures, encounter/initiative,
scene, log, RNG position, players. It is derived from and saved back to durable
storage every turn, which is what makes the service horizontally scalable.
GameRuntime(runtime.ts) owns the lifecycle:loadSessionhydrates (Redis shared-state → falling back to the Postgres snapshot),saveSessionwrites both back;createAdventure/joinAdventureset up the party and append to each character's cross-adventure log.Realtime(realtime.ts) is one interface with three jobs:broadcast()(pub/sub snapshot fan-out),lock()(per-adventure turn serialization), andpresence()(who's connected). It has an in-process implementation (single instance / local dev) and a Redis implementation (multi-instance), chosen byREDIS_URL.- Because every turn runs under the lock and reloads the shared state, two instances can serve the same adventure correctly: combat started on instance A is visible to a player whose socket lives on instance B.
dm.tsdefines theDMinterface, theScriptedDM(deterministic, offline default), the sharedSYSTEM_PROMPT, theDM_TOOLSschema, andbuildStateContext(what the model sees: party sheets, enemies, whose turn it is, recap, key facts, recent events). See DESIGN.md §3, §10.gameEngine.tscreateTools(session, images, store?)is the only way the DM mutates game state. Each tool calls the engine and logs the result:attack,startCombat,advanceTurn,runNpcTurns,cast_spell, plus the cross-adventure carry-over tools (grant_item,adjust_gold,award_xp,record_relationship) that also write back to the canonicalCharacterin the store viaupdateCanonical.geminiDm.tsis the Gemini provider (tool-calling via@google/genai), with defensive parsing, a round cap, narration scrubbing, and graceful fallback toScriptedDMif the backend is unreachable.
providers.ts selects implementations from env so the demo runs with no cloud:
| Concern | Env | Default (offline) | Real backend |
|---|---|---|---|
| DM | DM_PROVIDER |
scripted |
gemini (Vertex AI or AI Studio) · ollama (local) |
| Images | IMAGE_PROVIDER |
placeholder SVG cards | gemini (Imagen + gemini-2.5-flash-image for reference-conditioned consistency) |
| Store | DATABASE_URL |
JsonStore (file) |
PostgresStore (pgvector) |
| Realtime | REDIS_URL |
in-process | Redis pub/sub + lock |
If a real backend errors at runtime, the affected path falls back (e.g. the DM to scripted narration, images to placeholders) rather than failing the turn.
store.ts defines the async Store interface and the JsonStore (single JSON
file, dev) implementation; postgres.ts is the PostgresStore (queryable columns
promoted out of JSONB; pgvector enabled for future semantic recall). Core entities:
User— account (scrypt-hashed password, bearer token viaauth.ts).Character— the canonical, cross-adventure hero: meta (race/class/background/level), the engineCreaturestat block + primary attack (sheet),xp,adventureLog,spellsCurated, portrait fields. Persisted spell/inventory/roleplay live in the sheet JSONB; xp/log/flags ride in anextraJSONB column.Adventure— owner, party, invite code, and asnapshot(the serialized live session) for durable resume.
The live session ↔ snapshot conversion is toSnapshot / fromSnapshot in
session.ts; toView(session) produces the SessionView the client renders
(including a per-PC PlayerSheetView so a player can open their sheet mid-game).
srd.ts— the SRD catalog (races/classes/backgrounds),buildCharacter(validate choices → derive an authoritative stat block + attack),awardXp(level-ups recompute HP/proficiency/spellcasting), andensureCharacterFields(idempotent backfill that upgrades older sheets on read).srdContent.ts— spell pools, the SRD cantrips-known / spells-known (or prepared) count tables,buildSpellcasting(enforces counts & legality; reserves the class's signature attack cantrip), and starting inventory (class kit- background kit) / gold.
suggest.ts— LLM-assisted, with offline heuristic fallback:suggestCharacterBuild(back-story → race/class/background/skills) andchooseSpells(back-story + skills → a thematic, legal spell selection; the engine still enforces counts).
memory.ts implements the rolling recap + key-facts model so context stays roughly
O(1) in campaign length: the lossless session.log is the event record; older turns
are summarized at scene boundaries into recap, and durable decisions are promoted
to keyFacts. The full rationale and the retrieval design are in
DESIGN.md §5–§6.
useGame.ts— the WebSocket hook: connects, joins, tracks the latestSessionView, exposessendAction. Auto-reconnects.screens/—Login,Dashboard,CharacterCreator,CharacterViewer,AdventureCreator,InviteJoin, andPlay(the live table).components.tsx—Scene,MapBoard(tactical grid),Log,Sidebar(party/initiative/HP), andCharacterSheetPanel(the in-play sheet drawer).auth.tsx— auth context (useAuth), backed byapi.ts.- All shared shapes come from
@ai-dm/protocol— never redefine wire types in web.
packages/engine/src/ — rng (seeded mulberry32), dice, abilities,
skills, conditions, damage, creature (stat block + allSkills/allSaves),
combat, grid, encounter, index (barrel). Each has a *.test.ts.
packages/server/src/
| File | Responsibility |
|---|---|
index.ts |
App bootstrap, /ws handling, the turn loop (handleAction/processTurn). |
routes.ts |
REST API (auth, SRD catalog, characters, adventures, portraits). |
runtime.ts |
GameRuntime: session load/save, create/join adventures. |
realtime.ts |
Realtime: broadcast / lock / presence (in-process + Redis). |
session.ts |
Live Session, toView, snapshot ↔ restore. |
dm.ts |
DM interface, ScriptedDM, system prompt, tool schema, state context. |
geminiDm.ts |
Gemini DM provider (tool-calling loop). |
gameEngine.ts |
createTools — the engine-backed tool layer the DM calls. |
srd.ts / srdContent.ts |
Character building, spells, inventory, XP/level-ups. |
suggest.ts / suggestions.ts |
Build/spell suggestions; per-turn action hints. |
memory.ts |
Rolling recap + key facts. |
store.ts / postgres.ts |
Store interface; JSON + Postgres implementations. |
images.ts |
Image provider interface (placeholder + Gemini/Imagen). |
content.ts / adventureTemplates.ts |
Demo content + the five pre-canned adventures. |
auth.ts |
Password hashing + token issue/verify. |
packages/web/src/ — main.tsx, App.tsx (routes), api.ts, auth.tsx,
useGame.ts, components.tsx, types.ts, styles.css, screens/.
- TypeScript is strict (
tsconfig.base.json):noUncheckedIndexedAccess,verbatimModuleSyntax(useimport typefor type-only imports), NodeNext ESM (.jsextensions on relative imports, even from.tssources). - Never use global
Math.random()in the engine or game logic — thread the session's seededRNGso turns are reproducible and snapshots are exact. - Don't redefine wire types in the web — import from
@ai-dm/protocol. - PC-only sheet detail.
CreatureView.sheetis populated only for player characters; enemies/NPCs must never expose a stat block to the client. - Verify before claiming done:
npm run typecheck && npm testfrom the root (build the engine first). See ../AGENTS.md.