dmcheck
Deterministic conduct verdicts for live tabletop sessions — CI for running a game.
Feed it a session transcript (and optionally an engine event ledger) plus a table charter, and it returns named findings — the player whose question was never answered, the dice roll nobody acknowledged, the turn that began without anyone being told, the spoiler that leaked into the channel, the five-minute dead air. Every finding cites the charter rule it violates, with the evidence attached.
Cold-boot probe (2026-07-24): a fresh agent session given only this repo URL installed and refereed a session in 2 commands, verified all three exit-code legs against the docs, and confirmed the no-false-accusation contract held (a consumed roll produced silence). Its friction notes shipped as 0.1.1 (--dice-bot, dmcheck charter).
The design contract: a false accusation is the unforgivable bug. A rule fires only when the transcript provably shows the violation — ambiguity produces silence, never noise. The verdict path is model-free and deterministic: same transcript, same findings, every time.
30 seconds to a refereed session
$ pip install dmcheck
$ dmcheck run session.jsonl --gm "Greta"
{
"messages": 9,
"findings": [
{"rule": "R2", "summary": "unconsumed-roll: a dice result was never followed by any GM message",
"charter": "roll_ack_within_messages=4",
"detail": "dice result from DiceBot never followed by a GM message",
"evidence": {"index": 6, "author": "DiceBot", "content": "Bram rolls 1d20+4: [18] = 22"}},
...
],
"counts": {"R1": 1, "R2": 1, "R6": 1, "R7": 1, "R8": 1}
}
Transcript formats: JSONL of {ts, author, content}, or a JSON array of Discord-API-shaped messages ({timestamp, author: {username}, content}) in either order.
The rule set (each one paid for by a real table failure)
| Rule | Fires when | Origin story |
|---|
| R1 | a player's question got no GM response within threshold | a player asked the DM a lore question; another player ended up answering |
| R2 | a dice result was never followed by any GM message | "did I hit?" — a player's successful attack roll sat unacknowledged |
| R3 | an engine event was never narrated to the table | the state engine resolved a hit the table never heard about |
| R4 | a turn began and the GM never addressed the actor by name | "isn't it her turn?" — asked by a player, which is one player too many |
| R5 | someone acted out of initiative (needs the ledger) | engine rejected it silently; the table never knew |
| R6 | a configured hidden term appeared in a GM message | a module's secret state names leaked into narration |
| R7 | GM dead air beyond threshold while a player waited | 30 seconds reads as thinking; five minutes reads as absence |
| R8 | the session ended with open R1–R3 findings in its tail | sessions should end in a defined state — that's what makes the next one possible |
These came from running a hybrid table — human and AI players, an AI GM — on Discord, where every one of these failures actually happened and got codified the same week. They apply equally to human GMs: run dmcheck over your own exported game log and see what your table's transcript says.
Live mode (0.2): the referee sits AT the table
dmcheck watch runs the same engine over a growing session — stdin JSONL or a
tailed file — and emits lifecycle events: OPEN when a violation becomes
provable (thresholds fully elapsed; no predictions, ever), RESOLVED when a
living condition heals (the engine event finally got narrated). --notify-cmd
fires your own hook per OPEN finding — dmcheck itself never posts anywhere.
At session end a closed-mode pass runs, so watch's final state provably equals
dmcheck run on the full transcript (tested).
$ your-chat-fetcher | dmcheck watch - --gm "Rob" --notify-cmd 'notify-dm.sh'
{"event": "open", "rule": "R2", "detail": "dice result from RollBot never followed by a GM message", ...}
{"event": "resolved", "rule": "R3", ...}
{"event": "session_end", "open": ["R1"], "open_count": 1}
$ dmcheck explain R2
$ dmcheck lint-charter my.json
The point of live: every failure the rules encode was recoverable in the
moment it happened — the unanswered question, the stale roll, the missing cue
all had a seconds-wide window where a nudge saved the beat. Post-hoc tells you
what went wrong last night; watch taps the GM's shoulder before the player
feels it.
The charter is config, not code
charters/default.json ships thresholds and conventions derived from a real table's protocol. Override any of it — cue conventions, dead-air tolerance, dice-bot names, hidden-term lists — and version it. A league or organized-play program could publish a charter the way they publish a player's guide; dmcheck then referees any table against it.
$ dmcheck run session.jsonl --charter our-table.json --ledger events.jsonl
$ dmcheck rules
$ dmcheck charter
$ dmcheck run session.jsonl --gm "Rob" --dice-bot "RollBot"
$ dmcheck --schema
For agents
tool.json at the repo root; --schema; exit codes: 0 clean · 1 findings · 2 charter/input unusable.
- MCP server:
dmcheck-mcp (stdio) with tools run and rules.
- Findings are structured JSON with rule id, charter citation, human-readable detail, and an evidence span — built to be consumed by a GM agent that fixes its own procedure between beats.
What it does NOT do (on purpose)
- No rules adjudication — whether the attack was legal is srdcheck's job.
- No character math — that's charactercheck. (srdcheck judges the rules, charactercheck derives the actor, dmcheck referees the table.)
- No narrative-quality judging — whether the prose was good is taste, and taste is not checkable. dmcheck checks procedure only.
- No model calls, no scores — deterministic findings per rule, never a blended "DM grade."
Credits
The rule set was distilled from live hybrid (human + AI) table sessions; the Router+Detector pattern in native-gaming-harness independently converged on the same idea, which we take as evidence it's the load-bearing piece. dmcheck is game-system-agnostic and unaffiliated with any publisher.
mcp-name: io.github.chaoz23/dmcheck
Bootstrap a new table (v0.3)
dmcheck init charter.json --gm YOUR-NAME
Writes a starter charter (versioned, effective-dated, lint-clean by
construction) and prints the session-zero checklist S1–S8 — including S3c:
sheet accountability is declared out loud at session zero, then settlement
quizzes are graded silently. The referee that judges your table also hands
you its constitution.
Per-seat cue policy (R4, hardened). Agent seats behind mention-gated
transports (e.g. Discord allowBots="mentions") never receive name-in-prose
cues. Declare it:
"seats": {"Shalia": {"cue_requires_mention": true, "mention": "<@1493...>"}}
R4 then counts a cue only if the literal mention string is present.
Origin: a live session where "Shalia — you're up" was posted, looked like a
cue, and was provably undeliverable — R4 passed on it. Never again.
Ledger format (the declared standard). dmcheck's ledger is JSONL:
{ts, type: turn|act|event, actor, text} — one line per engine event. No
lightweight OSS session-ledger existed when we surveyed (2026-07-26), so
this format is the interchange standard our stack shares: engines tap their
logs into it; run, watch, and settlement all consume it.