A tabletop RPG you play in Slack and on the web, set inside a fantasy version of the AI industry — and a low-risk, hands-on way to learn how agents at Together actually work. Built as an Abacus extension.
Web Hub (canvas tech demo): https://hub.tap.t6r-ai.dev
— Okta-gated DM console with live Abacus dynamic canvas. Mesh twin:
https://hub.mesh.tap.t6r-ai.dev. See
web/README.md and docs/DEPLOY.md.
In this realm the currency is tokens (the LLM kind), magic is inference drawn from GPUs, and the monsters are the things that break real agents — rate limits (the 429 Hydra), out-of-memory ogres, cold-start wraiths, the 503 Dragon. The villain is the Token King, a closed-weights monopolist who hoards every GPU and every minted token — alone. You play underdog open-source builders from Together who win the only way Together wins: by working together.
The DM is an Abacus agent that narrates the story and teaches the machinery underneath: every move it makes is a real tool call (surfaced in a 🛠 footer), your Slack channel is a multi-user session, and the world it remembers lives in a database. It is a deliberately low-risk sandbox — fork it and add a tool, a campaign, or a skill to a real, production-shaped agent with zero production data or business logic at stake. It's whimsical and family-friendly by design — a fun thing to play with your team.
> jkail: We're broke open-source builders — we march on the Token King.
🐉 The Token King laughs from the Aureate Bastion, his GPU hoard humming with
stolen heat. "Come back when you can afford a single token," he sneers.
_🛠 create_entity, create_entity, award_rp · 2 rounds_ ← the agent, working
> jkail: I emerge from the Foundry. What are my stats now?
🔥 You rise as an ARCHMAGE OF INFERENCE — your weights are open, your mana has no
token price. You can crack the Bastion precisely because it was built from
tokens you no longer have to buy.
_🛠 update_character, create_entity, award_rp · 2 rounds · +10 RP_
Playing the DM is a tour of how agents work — each concept is visible in the game:
| Agent concept | How the game shows it |
|---|---|
| Tool calls | The DM's "spells" are tool calls; the 🛠 footer lists the ones it made each turn. |
| The agentic loop | A turn is a ReAct loop — think → call tools → observe results → repeat → narrate (src/ai/dm/agent.ts). |
| Multi-user sessions | One Slack channel = one shared Abacus session; the party is everyone in the channel. |
| Persistent memory | The world (NPCs, factions, your character) lives in a Drizzle/SQLite DB and survives restarts. |
| Skills | The agent profile loads skills (session-awareness, context-pressure) — see profiles/dungeon-master.md. |
| Models & rate limits | It runs on Together's Kimi models; hit the 429 Hydra and watch the SDK's retry/back-off kick in. |
The fantasy is a love letter to where it's built — Together AI, "The AI Native Cloud," on a mission to lower the cost of AI for everyone and a bet that the future of AI is open source. The game wears that on its sleeve:
- Open vs. closed — the Token King personifies closed, proprietary AI; you win as open-source builders (open weights, open data, shared kernels) — the one thing infinite compute can't buy.
- The four values — the guild lives Together's values: empower innovation, discover the magic, do more with less, model stewardship.
- Real open gifts as legendary relics — the Flash (attention kernel), the RedPajama Trove (open dataset), the Thunder Kittens.
- Real infra trends — The Capacity Crunch is a whimsical build-out against power (a ~200 MW ley-line), cooling, GPU supply (GB200s), vendors, and outages; Descent into the Data Center is heroic, family-friendly on-call.
(All in good fun and family-friendly — the game never names or disparages real companies.)
This repo is a safe place to ship your first feature on a production-shaped agent — there's no business logic or real data to break. Good starter PRs:
- Add a DM tool in
src/ai/dm/tools.ts(e.g.roll_dice,summon_npc) — it becomes a new "spell" the DM can cast, and shows up in the 🛠 footer. - Add a campaign in
src/ai/campaigns/index.ts— a new themed world with its own rules and arc (copyThe Token Kingand reskin it). - Add a class or monster to the deterministic engine in
src/game/content.ts. - Tweak the persona in
src/extension/profile.ts/profiles/dungeon-master.md.
Then prove it locally with npm test and npm run demo:ai (both offline, no
network) before you ship.
There are two ways to run it. Pick based on whether you want a dedicated DM bot or just the tools inside your existing Abacus bot:
| A · Dedicated DM agent (recommended) | B · In-process extension | |
|---|---|---|
| You get | Its own DM bot + AI assistant pane, separate from Abacus |
dnd_* / dnd_ai_* tools inside your existing Abacus bot |
| Best when | You want a standalone, always-on Dungeon Master | You already run an Abacus Slack bot and just want the game tools |
| Effort | Slack app + own abacus serve + a 2nd bridge |
One symlink + restart |
| Full steps | Run it as a Slack agent | Install into Abacus |
Fast path (Option A — dedicated DM agent):
# 0. build this repo
npm install && npm run build
# 1. install both extensions + the agent profile onto the DM serve
ln -sfn "$PWD" ~/.abacus/extensions/abacus-dnd # the game (dnd_ai_*/dnd_*)
ln -sfn "$HOME/Documents/abacus-slack/extension" ~/.abacus/extensions/abacus-slack-tools # slack_* (dependency)
ln -sfn "$PWD/profiles/dungeon-master.md" ~/.abacus/profiles/dungeon-master.md # the agent profile
# 2. create the Slack app from slack/manifest.json → api.slack.com/apps → "From a manifest"
# (provisions the DM bot + /dungeon command + assistant pane; grab the xoxb-/xapp- tokens)
# 3. fill the serve env, then start serve + a second bridge
$EDITOR ~/.abacus/dungeon-master-serve.env # SLACK_BOT_TOKEN + TOGETHER_API_KEY + DM knobs
./scripts/serve-dungeon-master.sh # dedicated serve on :19818
cp bridge.env.example ~/.abacus/dungeon-master-bridge.env && $EDITOR ~/.abacus/dungeon-master-bridge.env
cd ../abacus-slack && npm run build -w bridge
node --env-file="$HOME/.abacus/dungeon-master-bridge.env" bridge/dist/index.jsThen invite the bot to a channel and play — @DM start a Token King campaign, then join GPU Wizard as Cuda → start → attack / cast. Each
channel is its own persistent saga. The detailed walkthrough (env vars, Socket
Mode tokens, how to stop just the DM bot) is in
Run it as a Slack agent
and slack/README.md.
Fast path (Option B — tools in your existing bot):
npm install && npm run build
ln -sfn "$PWD" ~/.abacus/extensions/abacus-dnd # ExtensionLoader reads manifest.json → createTools(deps)
# restart your abacus serve; verify with: abacus manifest | grep dnd_Once loaded, players drive it with the same in-channel commands. See Install into Abacus for the hand-wired variant.
The earlier Slack thread narrated a game by hand. This is the real thing: a
typed, tested game engine that registers dnd_* tools with Abacus and is driven
by actual Slack events. The engine is pure and reproducible — every dice roll
flows through an injectable RNG, so the same seed + same inputs always produce
the same game (see src/game/engine.test.ts).
Slack message
│
▼
dnd_play tool ──► dispatch() ──► parseCommand() (text → intent)
│ │
│ ▼
│ engine.* (pure, RNG-injected mutations)
│ │
▼ ▼
GameStore LogLine[]
(Memory | KV) │
▼
format.* (Slack mrkdwn / Block Kit)
| Layer | File | Responsibility |
|---|---|---|
| Dice | src/game/dice.ts |
Seedable PRNG + scripted RNG for tests |
| Types | src/game/types.ts |
Plain-data domain model (JSON-serialisable) |
| Content | src/game/content.ts |
Classes, bestiary, shop — all balancing lives here |
| Engine | src/game/engine.ts |
Pure game logic: join, combat, turns, loot |
| Parser | src/game/parser.ts |
Slack text → structured command (+ aliases) |
| Store | src/slack/store.ts |
One game per channel (Memory or KV-backed) |
| Dispatch | src/slack/dispatch.ts |
Glue: text → engine → persist → reply |
| Format | src/slack/format.ts |
Slack mrkdwn + Block Kit renderers |
| Tools | src/extension/tools.ts |
dnd_* Abacus tools + createTools entry |
Following the Abacus extension-authoring contract — every tool is
dnd_verb_noun (so it surfaces as tools.dnd.verbNoun in the REPL) with a
z.object({...}) schema (so repl_tool_index derives signatures):
| Tool | Purpose |
|---|---|
dnd_play |
Process one player message and return the narration. This is the one the Slack handler calls. |
dnd_status |
Render the current party + foe + phase for a channel. |
dnd_shop |
List the shop items. |
dnd_help |
Return the how-to-play text. |
dnd_reset |
Delete a channel's game so a fresh party can form. |
| Command | Aliases | What it does |
|---|---|---|
join <class> [as <name>] |
play, bare class name |
Join the party. Classes: warrior, mage, cleric, rogue |
shop / buy <item> |
store |
Browse / buy gear before the fight |
start |
begin, ready, go |
Begin the adventure |
attack |
hit, strike |
Weapon attack |
cast |
spell, magic |
Cast your class spell |
defend |
block, guard |
Halve the next incoming hit |
use <item> |
quaff, drink |
Use a consumable (potions) |
flee |
run, retreat |
Attempt to escape (initiative check) |
status |
party, sheet |
Show the party + foe |
help |
commands, ? |
Show command help |
npm install && npm run build
# Drop this directory under your configured extensions root.
# The loader reads manifest.json and calls createTools(deps).manifest.json declares the tool module, the createTools entry point, and the
Slack subscription (app_mention / message.channels → dnd_play).
import { createTools } from "abacus-dnd-slack";
import { KvGameStore } from "abacus-dnd-slack";
// Persist games in Abacus KV so they survive restarts.
const store = new KvGameStore(abacusKv, "dnd:game:");
toolRegistry.registerAll(createTools({ store }));When the bot is mentioned in a channel, call dnd_play:
slackApp.event("app_mention", async ({ event, say }) => {
const out = await toolRegistry.execute("dnd_play", {
channelId: event.channel,
userId: event.user,
userName: await resolveDisplayName(event.user),
text: event.text, // the leading <@bot> mention is stripped automatically
});
if (out.handled) await say(out.reply);
});That's it — dnd_play parses the text, advances the per-channel game, persists
it, and hands back the narration to post.
npm install
npm run typecheck # tsc --noEmit, strict
npm test # 94 vitest tests (engine, parser, dispatch, tools, agentic DM, DM tools, Drizzle, LLM tool-calling)
npm run demo # play a scripted dungeon crawl in the terminal
npm run demo:ai # replay the Token King saga through the AI DM (offline, deterministic)
npm run build # emit dist/- Pure engine, injected RNG. No
Math.randomin game logic — all randomness goes throughRng. Production uses a seededmulberry32; tests use a scripted RNG that replays a fixed queue. This is what makessame seed + same inputs → same gamehold (proven in the tests). - Plain-data state.
GameStateis fully JSON-serialisable, so a game can be parked in KV and rehydrated. TheKvGameStoreadapter does exactly this with any{get,set,delete}KV-ish object. - One game per channel. The store is keyed by
channelId, so different Slack channels run independent dungeons. - Swappable presentation. Only
src/slack/format.tsknows the Slack wire format. Porting to Discord or a TUI means rewriting that one file.
MIT
Everything above is the deterministic combat engine — the seeded, mechanical fallback. The headline act is the agentic, LLM-driven DM: a tool-calling agent that turns the game into freeform, emergent play — invented AI classes (Prompt Engineer, GPU Wizard, RAG Ranger), fine-tuning arcs, ascensions, alliances and cartels, and the DM even joining the fiction as Abacus the Familiar. Because the world is the AI industry, explaining the magic is explaining how agents work.
The DM is non-deterministic and creative — it takes liberties on the story — but stays strict to each campaign's theme and rules. Crucially, it is a tool-calling agent: every turn it can persist whatever the story needs into the database (NPCs, locations, items, quests, lore, factions, character ascensions, RP awards) across multiple reasoning rounds, then narrate. State accrues over the life of a campaign — and the 🛠 footer shows you each tool call.
> jkail: I emerge from the Foundry. What are my stats now?
🔥 You rise as an ARCHMAGE OF INFERENCE — your weights are open, your mana has no
token price. You can crack the Bastion precisely because it was built from
tokens you no longer have to buy.
_🛠 update_character, create_entity, award_rp · 2 rounds · +10 RP_
=== Persisted world entities (created by the DM via tool calls) ===
(location) The Aureate Bastion [open] — the closed-source fortress, gates ajar to an empty hall.
(npc) The Token King — owns every GPU and every minted token; infinitely rich, infinitely alone.
(location) The Foundry — a volcano-cooled data center the Token King does NOT own.
(lore) The Doctrine of Unminted Mana — inference with no token price cannot be bought or rate-limited.
=== Roster + alliances ===
jkail the Archmage of Inference — open-weights, L5, RP 37
[alliance] The Open Forge: jkail, npc:abacus
Run the offline agentic saga replay (LLM stubbed with a scripted tool program, fully deterministic, real DB + real DM tools):
npm run demo:aiSlack message
│
▼
dnd_ai_play ──► AiGameService.play()
│
├─► campaigns/ (strict theme + rules + arc → system prompt)
│
├─► AgenticDungeonMaster.run() ── the ReAct tool-calling loop ──┐
│ │ │
│ │ round N: llm.chatWithTools(msgs, DM_TOOLS) │
│ │ ├─ model requests tools ─► run each DmTool ─┐ │
│ │ │ (mutates DB) │ │
│ │ └─◄ tool results fed back ◄─────────────────┘ │
│ │ …loop until the model stops calling tools… │
│ └─► final assistant text = narration ◄───────────────────┘
│ │ │ │
│ ▼ ▼ ▼
│ to Slack RP (award_rp) world state
│ (create/update_entity,
▼ factions, ascensions)
Drizzle ORM ──► node:sqlite (Node 22 built-in, zero native deps)
profiles · characters · turns · campaigns · score_events · factions ·
faction_members · entities · entity_relations
| Module | File | Responsibility |
|---|---|---|
| Contracts | src/ai/contracts.ts |
The integration seam: tool-calling LlmClient, repo interfaces incl. EntityRepo, DmTool, Campaign, Faction |
| LLM | src/ai/llm/ |
TogetherLlmClient (Kimi, 3 tiers, native tool-calling) + FakeLlmClient (scripted tool programs) |
| Persistence | src/ai/db/ |
DrizzleDatabase — Drizzle ORM over Node 22's node:sqlite (zero native deps). Schema in drizzle-schema.ts |
| Campaigns | src/ai/campaigns/ |
Themed prompt packs (The Token King; The Capacity Crunch; Descent into the Data Center) with strict rules + ascension arcs |
| DM | src/ai/dm/ |
AgenticDungeonMaster (the loop) + DM_TOOLS (the agent's toolbox) |
| Service | src/ai/service.ts |
AiGameService facade: lobby → play → leaderboard; composes the strict prompt + records turns |
| Profile | src/extension/profile.ts |
The shipped dm Dungeon-Master persona/config pack |
| Tools | src/extension/ai-tools.ts |
The dnd_ai_* Abacus tools |
The agent calls these to read + persist the world over time. New nouns the
schema never anticipated live in the generic entities store (kind +
free-form JSON data), wired together by typed entity_relations edges.
| Tool | What the DM does with it |
|---|---|
create_entity / update_entity / delete_entity |
Invent + evolve any world object (NPC, location, item, quest, lore, rumour…) as a typed JSON entity with a stable key |
get_entity / list_entities |
Read current world state (filter by kind/status/search) before narrating |
relate_entities / relations_for |
Wire directed, typed edges (guards, owns, hides, located_in…) between entities/actors |
award_rp |
Grant (or dock) roleplay points — updates the turn, character, and lifetime profile score |
update_character |
Progression / rename / re-class / level bump / stat merge (ascensions live here) |
create_faction / set_faction_member / list_factions |
Found + evolve alliances / cartels / guilds with members + loyalty |
Tools are pure functions over a DmToolContext (the live Database + current
campaign/turn). They never throw — bad args return { ok:false, error } so the
model can recover mid-loop.
| Tool | Purpose |
|---|---|
dnd_ai_play |
Run one player message through the agentic DM. The one the Slack handler calls. Returns narration + RP awarded + which DM tools fired + arc stage. |
dnd_ai_start |
Start (or fetch) a channel's campaign; pick a theme with templateId. |
dnd_ai_join |
Join with any class/title (invented classes honoured) + persona. |
dnd_ai_status |
Roster + factions/alliances + persisted world entities + arc stage. |
dnd_ai_leaderboard |
Top roleplayers by lifetime RP score. |
dnd_ai_campaigns |
List available campaign themes. |
dnd_ai_conclude |
Conclude the channel's campaign. |
This ships as a dedicated Slack agent app (the Abacus Dungeon Master),
separate from the generic dnd extension:
slack/manifest.json— the app manifest (bot userDM,assistant_viewAI-agent surface with suggested prompts, OAuth scopes, Events API subscriptions, Socket Mode). Paste it at api.slack.com/apps → Create New App → From a manifest. Full walkthrough inslack/README.md..env.example— every secret/token the app needs (SLACK_BOT_TOKEN,SLACK_SIGNING_SECRET,SLACK_APP_TOKEN,TOGETHER_API_KEY, per-tier model overrides, DM profile knobs). Copy to.env(gitignored) and fill in.profiles/+src/extension/profile.ts— two distinct "profiles":profiles/dungeon-master.md— the Abacus agent profile (an abacus-native disk profile,systemPromptMode: replace). This is what makes the Dungeon Master a first-class agent overabacus serve(see below). It allowlistsdnd_ai_*/dnd_*andslack_*, and declares its dependency on the abacus-slack tools extension.src/extension/profile.ts— the in-fictiondmDM persona pack (persona, narration style, signature moves, default campaign, thenpc:abacus"Abacus the Familiar" identity). Resolve it withresolveDmProfile(id); selected viaDM_PROFILE.
Beyond loading as an in-process extension, the Dungeon Master can run as a
dedicated Slack agent end-to-end — its own abacus serve (own port + own
bot token) behind a second abacus-slack bridge instance, so
it appears as the DM bot, distinct from the generic Abacus bot.
This mirrors the DrInference extension's pattern exactly.
Slack (DM app) ──▶ abacus-slack bridge ──HTTP/SSE──▶ DM serve (:19818, own SLACK_BOT_TOKEN)
@DM … (ABACUS_PROFILE= ├─ this game extension → dnd_ai_* / dnd_* + dungeon-master profile
dungeon-master, └─ abacus-slack-tools → slack_* (DEPENDENCY)
:19818)
Abacus app ──▶ bridge ──▶ Abacus serve (:19816) DrInference app ──▶ bridge ──▶ DrInference serve (:19817)
The bridge is generic: ABACUS_PROFILE=dungeon-master selects the toolset +
system prompt server-side, so all the behaviour comes from
profiles/dungeon-master.md — no bridge code changes.
1. Build + install both extensions onto the DM serve:
npm install && npm run build # this repo → dist/
ln -sfn "$PWD" ~/.abacus/extensions/abacus-dnd # the game (dnd_ai_* / dnd_*)
ln -sfn "$HOME/Documents/abacus-slack/extension" ~/.abacus/extensions/abacus-slack-tools # slack_*
ln -sfn "$PWD/profiles/dungeon-master.md" ~/.abacus/profiles/dungeon-master.md # the agent profile2. Create the DM Slack app from slack/manifest.json
(api.slack.com/apps → From a manifest), then generate an App-Level Token with
connections:write (→ SLACK_APP_TOKEN) and install it (→ SLACK_BOT_TOKEN).
Full walkthrough in slack/README.md.
3. Fill the serve env ~/.abacus/dungeon-master-serve.env (the credentials
the in-process slack_* + dnd_* tools read — the SLACK_BOT_TOKEN here is
what makes them post as DM):
SLACK_BOT_TOKEN=xoxb-… # DM bot
TOGETHER_API_KEY=… # live Kimi DM narration
DND_DB_PATH=$HOME/.abacus/dnd.sqlite # persist the saga across restarts
DM_PROFILE=dm # in-fiction persona pack (default)
DM_DEFAULT_CAMPAIGN=token-king
TOGETHER_MODEL_LARGE=moonshotai/Kimi-K2.6 # agentic-narration tier4. Start the dedicated serve, then the bridge (two terminals, or tmux):
# serve — its own port (:19818) + own bot token; wait for "Server listening …"
./scripts/serve-dungeon-master.sh
abacus manifest | grep -E 'dnd_|slack_' # verify both extensions loaded
# bridge — copy the example, fill the Slack tokens, run a SECOND bridge instance
cp bridge.env.example "$HOME/.abacus/dungeon-master-bridge.env"
$EDITOR "$HOME/.abacus/dungeon-master-bridge.env" # xoxb-/xapp- tokens; ABACUS_SERVE_URL=:19818
cd ../abacus-slack && npm run build -w bridge
# use $HOME, NOT ~ — bash won't expand a tilde after --env-file=
node --env-file="$HOME/.abacus/dungeon-master-bridge.env" bridge/dist/index.jsNow @DM start a Token King campaign in Slack — replies, and any
slack_* actions, come from the DM bot. Each channel is its own
persistent saga.
Stop — target the DM only (every bridge runs the same bridge/dist/index.js,
so match on the env-file path, never on bridge/dist/index.js):
pkill -f 'serve --port 19818' # DM serve
pkill -f 'dungeon-master-bridge.env' # DM bridge⚠ Never
pkill -f 'bridge/dist/index.js'— that also kills the Abacus and DrInference bridges (same binary, different--env-file).
| Artifact | Purpose |
|---|---|
profiles/dungeon-master.md |
The Abacus agent profile (ABACUS_PROFILE=dungeon-master) — persona prompt + dnd_ai_*/dnd_*/slack_* allowlist + slack-tools dependency. |
scripts/serve-dungeon-master.sh |
Starts the dedicated abacus serve --port 19818 with the DM serve env. |
bridge.env.example |
Template for the bridge instance (Slack tokens + ABACUS_SERVE_URL + ABACUS_PROFILE). Copy to ~/.abacus/dungeon-master-bridge.env. |
slack/manifest.json |
The Slack app manifest (bot user, assistant view, scopes, Socket Mode). |
manifest.json#config.serve |
Declares the serve script, port, env file, and bridge template for tooling. |
The DM speaks to its model through one tiny LlmClient contract with two
interchangeable providers, chosen with LLM_PROVIDER:
LLM_PROVIDER |
Backend | Default model | Auth |
|---|---|---|---|
bedrock (default) |
AWS Bedrock Claude (Converse API) | us.anthropic.claude-sonnet-4-6, low extended thinking |
Keyless — AWS default chain (EKS Pod Identity in-cluster; your SSO/default profile locally) |
together |
Together.ai (Kimi), OpenAI-compatible | moonshotai/Kimi-K2.6 |
TOGETHER_API_KEY |
It routes calls across three configurable tiers (fast / medium / large);
by default each provider points all three at one model. Edit
src/ai/llm/config.ts or override via env:
# Bedrock (default provider) — no API key; uses the AWS default credential chain
export LLM_PROVIDER=bedrock
export AWS_REGION=us-west-2
export BEDROCK_THINKING=low # off|minimal|low|medium|high|xhigh
export BEDROCK_MODEL=us.anthropic.claude-sonnet-4-6 # all tiers (or BEDROCK_MODEL_LARGE=…)
# …or fall back to Together.ai (Kimi)
export LLM_PROVIDER=together
export TOGETHER_API_KEY=...
export TOGETHER_MODEL_LARGE=moonshotai/Kimi-K2.6The agentic loop needs a tool-calling-capable model on the
largetier. The Bedrock client (src/ai/llm/bedrock.ts) runs Claude Sonnet 4.6 with low extended thinking by default and round-trips the model's reasoning across tool-calling rounds (required by Anthropic when thinking is on). Bedrock credentials are never an API key — setAWS_REGIONand let the default chain (Pod Identity / SSO) supply them.
import {
createAiTools, createDrizzleDatabase, TogetherLlmClient, AiGameService,
} from "abacus-dnd-slack";
// Persist the saga to a file so the world survives restarts (Drizzle + node:sqlite).
const db = createDrizzleDatabase(process.env.DND_DB_PATH ?? "./dnd.sqlite");
const llm = new TogetherLlmClient({}); // reads TOGETHER_API_KEY; must support tool-calling
const service = new AiGameService({ llm, db });
toolRegistry.registerAll(createAiTools({ service }));
slackApp.event("app_mention", async ({ event, say }) => {
const out = await toolRegistry.execute("dnd_ai_play", {
channelId: event.channel,
userId: event.user,
userName: await resolveDisplayName(event.user),
text: event.text,
});
await say(out.reply);
});The DM grades roleplay itself, in-loop, via the award_rp tool — granting
points for vivid, in-character, on-theme play and docking rule-breakers. Awards
are recorded as score_events, rolled into each character's campaign score and
each profile's lifetime score, and surfaced on the leaderboard. The service
sums the turn's award_rp calls into rpAwarded for the Slack reply.
- Agentic, not single-shot. The DM is a ReAct tool-calling loop
(
chatWithTools→ run tools → feed results → repeat, capped bymaxRounds). It persists world state through tools and ends by emitting plain narration — no rigid output JSON contract. - Open-ended persistence. A generic
entitiesstore (free-formkind+ JSONdata) plus typedentity_relationsedges let the DM invent and persist any noun the story needs, over time — no schema change per new concept. - Strict but creative. The campaign
theme+ hardrulesare injected verbatim into the system prompt and enforced; everything else is the DM's canvas. - Drizzle ORM, zero native deps.
drizzle-orm/node-sqliteover Node 22's built-innode:sqlite— real ORM ergonomics with nobetter-sqlite3native build step. IdempotentensureSchema()makes a fresh DB play-ready. - First-class alliances. Factions/cartels/guilds are real rows with members and loyalty scores, so "arm's-length cartels" and betrayals persist.
- Fully testable, offline. Inject
FakeLlmClientwith a scripted tool program + an in-memory Drizzle DB; the 94 tests (andnpm run demo:ai) drive the real loop and real persistence with zero network.