Skip to content

Latest commit

 

History

History
206 lines (168 loc) · 11.7 KB

File metadata and controls

206 lines (168 loc) · 11.7 KB

Architecture — how the system works

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 one invariant that explains everything

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.

Packages

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: server and web consume engine via its compiled dist/. Build the engine first (npm run build --workspace @ai-dm/engine) before typechecking/testing the others. CI does this explicitly.

The turn lifecycle (the heart of the system)

Play happens over a WebSocket. A single player action flows like this (packages/server/src/index.ts):

  1. 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? }.
  2. 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 Redis SET NX; in-process otherwise).
  3. runtime.loadSession(adventureId) materializes the live Session (see Live state & scaling below).
  4. 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 with runtime.saveSession, and broadcasts the new snapshot.
  5. 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.

Live state & scaling

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: loadSession hydrates (Redis shared-state → falling back to the Postgres snapshot), saveSession writes both back; createAdventure / joinAdventure set 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), and presence() (who's connected). It has an in-process implementation (single instance / local dev) and a Redis implementation (multi-instance), chosen by REDIS_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.

The DM agent loop & tool layer

  • dm.ts defines the DM interface, the ScriptedDM (deterministic, offline default), the shared SYSTEM_PROMPT, the DM_TOOLS schema, and buildStateContext (what the model sees: party sheets, enemies, whose turn it is, recap, key facts, recent events). See DESIGN.md §3, §10.
  • gameEngine.ts createTools(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 canonical Character in the store via updateCanonical.
  • geminiDm.ts is the Gemini provider (tool-calling via @google/genai), with defensive parsing, a round cap, narration scrubbing, and graceful fallback to ScriptedDM if the backend is unreachable.

Providers (pluggable, degrade gracefully)

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.

Data model & persistence

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 via auth.ts).
  • Character — the canonical, cross-adventure hero: meta (race/class/background/level), the engine Creature stat block + primary attack (sheet), xp, adventureLog, spellsCurated, portrait fields. Persisted spell/inventory/roleplay live in the sheet JSONB; xp/log/flags ride in an extra JSONB column.
  • Adventure — owner, party, invite code, and a snapshot (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).

Character system

  • srd.ts — the SRD catalog (races/classes/backgrounds), buildCharacter (validate choices → derive an authoritative stat block + attack), awardXp (level-ups recompute HP/proficiency/spellcasting), and ensureCharacterFields (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) and chooseSpells (back-story + skills → a thematic, legal spell selection; the engine still enforces counts).

Memory & context (long-campaign continuity)

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.

Web client

  • useGame.ts — the WebSocket hook: connects, joins, tracks the latest SessionView, exposes sendAction. Auto-reconnects.
  • screens/Login, Dashboard, CharacterCreator, CharacterViewer, AdventureCreator, InviteJoin, and Play (the live table).
  • components.tsxScene, MapBoard (tactical grid), Log, Sidebar (party/initiative/HP), and CharacterSheetPanel (the in-play sheet drawer).
  • auth.tsx — auth context (useAuth), backed by api.ts.
  • All shared shapes come from @ai-dm/protocol — never redefine wire types in web.

File-by-file quick map

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/.

Conventions & gotchas

  • TypeScript is strict (tsconfig.base.json): noUncheckedIndexedAccess, verbatimModuleSyntax (use import type for type-only imports), NodeNext ESM (.js extensions on relative imports, even from .ts sources).
  • Never use global Math.random() in the engine or game logic — thread the session's seeded RNG so 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.sheet is populated only for player characters; enemies/NPCs must never expose a stat block to the client.
  • Verify before claiming done: npm run typecheck && npm test from the root (build the engine first). See ../AGENTS.md.