A play-by-post tabletop RPG management platform where the GM runs the game and Claude AI voices the NPCs and inactive player characters. Designed for async play: characters respond on their own schedule, the session log persists between sessions, and you can pick up exactly where you left off.
- What It Is
- How It Works
- Starting a New Campaign
- Feature Reference
- Tech Stack
- Architecture
- Local Setup
- Campaign Structure
- Environment Variables
- Running the App
- UI Walkthrough
- Dice Rolling
- AI Character System
- Adding Features
- Key Files Quick Reference
- Data Models
- API Reference
- Troubleshooting
GAIM is a browser-based game table for play-by-post RPGs. The GM types messages into a chat interface; player characters (PCs) and NPCs respond via Claude AI according to their individual character sheets, voice archetypes, campaign rules, and the session history.
The core loop:
- GM sets a scene and sends a message.
- The AI reads character sheets, rules, handouts, and recent history, then generates in-character responses for each relevant participant — staggered with realistic delays so they don't all reply at once.
- Responses appear in the chat log as if the characters are playing in real time.
- The GM reacts, the scene continues.
Everything persists to disk. Come back a week later, open the browser, and the log, players, scenes, and collected handouts are all there.
Browser (React) ←──SSE stream── Express backend ←──→ Claude API
│ │
│── POST /api/session/message ──→ │── writes SESSION.json
│ │── queues AI responses
│← SSE: new message events ─────── │── reads character .md files
│── injects rules + handouts
│── calls Claude per character
The backend is the single source of truth. The frontend never writes to disk — it posts to the API, which mutates SESSION.json and broadcasts changes over a persistent Server-Sent Events connection. Every open browser tab stays in sync automatically.
Click + New Game at the bottom of the left sidebar. Type a name — the modal shows a live preview of the slug as you type (e.g. typing Mörk Borg: The Dying World previews campaigns/mork-borg-the-dying-world). Hit Create Campaign.
The app scaffolds the full folder structure on disk and prefills MEMORY.md with a commented template. A success notice appears listing the next steps.
Open the new campaign folder in a text editor and add .md files to rules/. All files in that directory are auto-loaded and injected into every AI prompt. For a crunchy system, split by topic rather than dumping everything in one file:
rules/
├── core-rules.md ← dice, skill checks, death
├── combat.md ← initiative, attacks, damage, armor
└── classes.md ← class abilities, starting gear
Keep them as concise reference summaries — the AI needs mechanics it can apply, not lore prose.
MEMORY.md is injected into every AI prompt as always-on context. The file comes prefilled with a template; fill in what applies and delete what doesn't:
- Tone & Atmosphere — how the world should feel
- Setting — facts every character would know
- House Rules — rulings that override or extend the base system
- Recurring Factions & NPCs — persistent groups and named contacts
- Session Notes — running notes to carry between sessions
Open .env in a text editor, set DEFAULT_CAMPAIGN=your-campaign-slug, and restart the server (npm run dev). The server boots into one campaign at a time; this is the only way to switch.
With the new campaign loaded:
- Click the + next to SCENES in the left sidebar and toggle Session Zero on.
- Add a seat per player — give each a name (e.g. "Tom"). Each seat is auto-assigned a unique Jungian archetype, shown as small text below the name. Click change to override it.
- Create the scene. A SESSION ZERO section appears in the left sidebar listing all seats.
- Run the conversation. The AI voices each seat using their assigned archetype and no character sheet — they engage as players at a table: asking questions about the rules and world, discussing what kind of character they want to play, and working through character creation together. Guide them as GM and let the conversation drive the choices.
- When a player settles on their character, create their sheet at
campaigns/your-campaign/characters/slug.mdin a text editor outside the app. - Back in the app, an Assign character… dropdown appears on each unfilled seat in the SESSION ZERO sidebar section. Select the new file to link it — this promotes the seat to a full player record and carries the archetype over automatically.
Create a regular scene (no Session Zero toggle), add your PCs and any NPCs as participants, and start play.
- Markdown-based content: characters, NPCs, rules, handouts, and scenes are all
.mdfiles you edit in any text editor. MEMORY.md— a freeform context document injected into every AI prompt. Use it for house rules, campaign tone, recurring themes, or anything the AI should always know.- Handout watching: drop a new file into
handouts/while the server is running and it becomes available instantly (no restart needed). SESSION.jsonpersists the full game state between server restarts.
- Add players by selecting any character from the campaign roster.
- Track status per player: active, inactive, or dead.
- Each player is automatically assigned one of 12 Jungian archetypes that shapes their AI voice (see AI Character System).
- Poke button forces a character to respond right now, bypassing the normal timing delay.
Session Zero is a special scene mode for collaborative character creation before the campaign begins (or when a new player joins mid-campaign to replace a fallen character).
How it works:
- Click the + next to SCENES in the left sidebar and toggle Session Zero on.
- Add a seat for each player joining — give each seat a name (e.g. "Tom", "Dick", "Harry"). Each seat is auto-assigned a unique archetype, shown as small text below the name field. Click change to override it.
- Create the scene. A SESSION ZERO section appears in the left sidebar showing all seats.
- Run the conversation. The AI voices each seat using their assigned archetype — no character sheet needed. They engage as players at a table: asking questions, discussing character concepts, working through char-gen together. Guide them as GM.
- When a player has decided on their character, create their character
.mdfile incampaigns/your-campaign/characters/(outside the app, in any text editor). - Back in the app, use the Assign character… dropdown on their seat to link the new character file to that seat. This adds the player to the roster and automatically inherits the seat's archetype — no re-assignment needed.
When a character dies mid-campaign:
The player (and their seat) are gone. A new player joins with a fresh mini-SZ: click + next to SCENES, enable the Session Zero toggle, and add a single seat for the new player. Run the conversation, create the character file, claim the seat. The new player gets their own archetype reflecting their own personality.
Key points:
- Seats exist only for the SZ conversation — once a character is assigned, the seat is fulfilled.
- The archetype on the seat transfers directly to the player record, so the AI's voice in the SZ carries seamlessly into the campaign.
- A SZ scene behaves like any other scene for messaging and the chat log.
- Create named scenes with a participant list (PCs + NPCs).
- Switch the active scene to filter the chat log to just that scene's messages.
- Add NPCs to the current scene mid-game by @-tagging them in the GM input.
- Messages from before scenes existed still appear (legacy support).
- GM messages are clearly labeled. PC/NPC messages show the character's name and avatar.
- Edit or delete any message after the fact.
- Pin messages to keep them in the AI context window even as the log grows long (important clues, key decisions, etc.).
- Out-of-character text: wrap anything in
(parentheses)and it renders with a distinct OOC style. - @-mentions auto-complete from the current cast and render as styled tags.
- Markdown in messages:
**bold**,_italic_,~~strikethrough~~, code blocks, etc.
Type /r EXPRESSION in the GM input to roll dice. Results appear as a formatted message in the log. Full syntax in Dice Rolling.
- Characters respond automatically after GM messages.
- Two-round response system: initial replies first, then targeted follow-ups to whoever called them out.
- Characters won't respond if they weren't addressed and nobody expects them to chime in.
- Full prompt context includes: character sheet, voice archetype, campaign memory, game rules, scene NPCs, collected handouts, pinned messages, and the last 60 messages.
- Characters can roll dice natively during responses (via Claude tool use).
- Prompt caching enabled to keep token costs low.
| Layer | Technology |
|---|---|
| Frontend | React 18, Vite 5 |
| Markdown rendering | marked 18 |
| Backend | Node.js (ES modules), Express 4 |
| AI | Anthropic Claude SDK, claude-sonnet-4-6 default |
| Realtime | Server-Sent Events (SSE) |
| File watching | chokidar |
| Data storage | JSON + Markdown files (no database) |
| Dev tooling | concurrently, dotenv, node --watch |
No database. No auth layer. Designed to run locally or on a private server.
gaim/
├── backend/
│ ├── index.js ← Express app setup, SSE endpoint, middleware
│ ├── start.js ← Loads .env before anything else
│ ├── routes/
│ │ ├── campaign.js ← Campaign data + tag list endpoints
│ │ ├── session.js ← Messages, scenes, pins, handouts
│ │ └── agent.js ← Manual poke endpoint
│ └── lib/
│ ├── agentRunner.js ← Builds prompts, calls Claude, posts responses
│ ├── campaign.js ← Reads campaign directory, watches handouts
│ ├── archetypes.js ← 12 Jungian voice templates
│ ├── session.js ← SESSION.json read/write with write-chain
│ └── eventBus.js ← SSE broadcaster (all connected clients)
├── frontend/
│ ├── src/
│ │ ├── App.jsx ← All state, SSE listener, top-level callbacks
│ │ ├── components/
│ │ │ ├── LeftSidebar.jsx ← Campaign info, players, handouts, scenes
│ │ │ ├── ChatLog.jsx ← Message list, scene filter
│ │ │ ├── Message.jsx ← Individual message + edit/delete/pin
│ │ │ ├── GMInput.jsx ← Text input, dice, @-tag autocomplete
│ │ │ ├── RightSidebar.jsx ← Character sheets for active players
│ │ │ ├── SceneCreator.jsx ← Scene creation modal
│ │ │ └── Markdown.jsx ← Sanitized markdown renderer
│ │ └── *.css
│ └── vite.config.js ← Proxies /api → localhost:3001
├── shared/
│ └── dice.js ← Dice notation parser used by both sides
├── campaigns/
│ └── my-campaign/ ← Campaign data (edit freely in any text editor)
├── campaigns.config.json ← Campaign registry: slugs, display names, last active
├── .env ← Secret keys and local config (never commit)
└── package.json ← Root dev script (runs both servers)
All mutable state lives in SESSION.json on disk. The backend reads it fresh before every write to avoid conflicts. The frontend holds a copy in React state and rebuilds it from SSE events — it never reads the file directly.
SSE events broadcast: message, messageEdited, messageDeleted, messagePinned, playerAdded, playerRemoved, playerStatusChanged, sceneCreated, sceneActivated, participantAdded, handoutCollected.
- Node.js 18 or later
- An Anthropic API key (console.anthropic.com)
# From the repo root
npm run install:allThis installs dependencies in root, backend/, and frontend/.
Create .env in the repo root (see Environment Variables for all options):
ANTHROPIC_API_KEY=sk-ant-...
DEFAULT_CAMPAIGN=my-campaignSee Campaign Structure. A minimal campaign needs:
campaigns/my-campaign/
├── SESSION.json
├── characters/
│ └── hero.md
└── npcs/
└── innkeeper.md
Minimal SESSION.json:
{
"messages": [],
"players": [],
"scenes": [],
"activeSceneId": null,
"collectedHandouts": [],
"pinnedMessageIds": []
}Register the campaign in campaigns.config.json:
{
"campaigns": ["my-campaign"],
"lastActive": "my-campaign",
"names": {
"my-campaign": "My Campaign"
}
}The names map controls the display name shown in the sidebar header. If a campaign was created via + New Game, the name you typed is stored here automatically. If you created a campaign folder manually, you must add an entry to names — the fallback derives a title from the slug by splitting on hyphens and title-casing each word, which is lossy: diacritics, punctuation, and mixed case are not preserved (mork-borg-rotblack-sludge becomes Mork Borg Rotblack Sludge, not Mörk Borg: Rotblack Sludge).
npm run devOpens:
- Frontend: http://localhost:5173
- Backend API: http://localhost:3001
Vite proxies all /api requests to the backend, so you only ever open the frontend URL.
Everything the AI knows comes from markdown files in the campaign directory. Edit them in any editor while the server is running — character and NPC changes take effect on the next agent call.
campaigns/my-campaign/
├── SESSION.json ← Game state (auto-managed, don't hand-edit during play)
├── MEMORY.md ← Always-on campaign context for the AI
├── characters/ ← Player character sheets
│ └── elara-swift.md
├── npcs/ ← Non-player character sheets
│ └── lord-vareth.md
├── rules/ ← Game system rules, tables, mechanics
│ └── core-rules.md
├── handouts/ ← Documents/evidence players can collect
│ └── mysterious-letter.md
└── scenes/ ← (managed at runtime, usually leave empty)
The filename becomes the slug (used in @-mentions and session data). The first # Heading becomes the display name.
# Elara Swift
## Background
A wandering ranger from the northern wastes...
## Stats
STR 12 | DEX 16 | CON 10 | INT 14 | WIS 13 | CHA 8
## Personality
Blunt, distrustful of authority, fiercely loyal once trust is earned.Injected wholesale into every AI system prompt. Use it for:
- Tone and setting guidance ("this is a gritty noir campaign, not high fantasy")
- House rules and rulings
- Things every character would know in-world
- Recurring NPCs or factions
- Session notes you want the AI to carry
Handouts are documents the GM can "collect" during play — a letter, a map description, a book excerpt. Once collected, they appear in the AI context for all subsequent responses. Drop a new .md file into the handouts/ folder anytime; the server picks it up without a restart.
All optional except ANTHROPIC_API_KEY.
| Variable | Default | Description |
|---|---|---|
ANTHROPIC_API_KEY |
— | Required. Your Anthropic API key. |
ANTHROPIC_MODEL |
claude-sonnet-4-6 |
Claude model to use for all agent calls. |
DEFAULT_CAMPAIGN |
— | Slug of the campaign to load on startup. |
CAMPAIGN_ROOT |
./campaigns |
Path to the directory containing campaigns. |
DEV_INSTANT_RESPONSES |
false |
Set to true to skip timing delays (responses fire immediately). Useful when writing/testing. |
PORT |
3001 |
Backend HTTP port. |
ALLOWED_ORIGIN |
http://localhost:5173 |
CORS origin whitelist for the frontend. |
NODE_EXTRA_CA_CERTS |
— | Path to a corporate proxy CA cert if needed for outbound HTTPS. |
# Development (both servers, hot reload)
npm run dev
# Backend only
cd backend && npm run dev
# Frontend only
cd frontend && npm run dev
# Production build
cd frontend && npm run build
# Then serve frontend/dist as static files and run backend directlyWith instant AI responses (good for testing content):
DEV_INSTANT_RESPONSES=trueWithout that flag, characters respond with realistic delays (5–10 minutes for initial responses, 30 minutes for follow-ups) to simulate async play.
- Campaign name at the top.
- Players section: shows all active players, their archetype, and status. Click Add Player to bring in a character. Click a player's status badge to cycle it. Poke fires an immediate AI response.
- Handouts section: lists all handouts in the campaign, with a button to collect each one. Collected handouts are highlighted and flow into the AI context.
- Scenes section: lists all scenes. Click to switch the active scene (filters the chat). The + button opens the scene creation modal.
- Messages scroll bottom-up (newest at bottom).
- Click the pin icon on a message to keep it in the AI context indefinitely.
- Click the edit icon (pencil) to edit a message inline.
- Click the delete icon (trash) to remove it from the session.
- Messages from outside the active scene are hidden — switch scenes or set no active scene to see everything.
- Type your message and press Enter (or the send button).
- Type
@to trigger character autocomplete. Multiple tags allowed. - Type
/r 2d6+3to roll dice inline. - OOC: wrap anything in
(parentheses)for out-of-character text. - Markdown works:
**bold**,_italic_, etc.
- Shows the full character sheet for each active player.
- Rendered as markdown. Update the
.mdfile and refresh to see changes.
Use /r EXPRESSION in the GM input. Examples:
| Expression | What it does |
|---|---|
/r d20 |
Single d20 |
/r 2d6+3 |
Two d6, add 3 |
/r 4d6k3 |
Roll 4d6, keep highest 3 (standard stat roll) |
/r 2d20dh1 |
Roll 2d20, drop highest (disadvantage) |
/r 2d20kh1 |
Roll 2d20, keep highest (advantage) |
/r 1d100 |
Percentile roll |
/r d% |
Percentile (alias) |
/r 4dF |
4 Fudge dice (results: -1, 0, +1) |
/r 2d6! |
Exploding d6s (reroll on max, add) |
/r 1d6+1d4+2 |
Mixed dice with modifier |
Results appear in the chat log with individual die results and the total.
Characters can also roll dice during their AI responses — the AI calls the same dice engine via a tool when it wants to make a check.
When a GM message is sent, the backend builds a system prompt for each character that should respond. It contains, in order:
- Behavioral instructions — character should not assert outcomes, ask questions rather than declare, stay in character.
- Character sheet — full content of the character's
.mdfile. - Voice archetype — one of 12 Jungian archetypes (see below).
- Campaign memory — full contents of
MEMORY.md. - Rules files — all
.mdfiles inrules/. - Scene NPCs — sheets for all NPCs in the current scene.
- Collected handouts — all collected handout documents.
- Session log — all pinned messages + the last 60 messages.
Prompt caching is applied to the system prompt (ephemeral cache, ~5 min TTL) to cut token costs significantly on rapid exchanges.
Each player is randomly assigned one of 12 archetypes when added to the session. The archetype shapes voice and tone but not character facts. Archetypes:
- The Innocent — hopeful, trusting, looks for good in others
- The Orphan/Everyman — pragmatic, grounded, relatable
- The Hero — courageous, driven, rises to challenges
- The Caregiver — nurturing, self-sacrificing, protective
- The Explorer — restless, curious, values freedom
- The Rebel/Outlaw — challenges rules, disruptive, provocative
- The Lover — passionate, committed, seeks connection
- The Creator — imaginative, expressive, values originality
- The Jester — playful, irreverent, uses humor to cope
- The Sage — analytical, objective, seeks truth
- The Magician — transformative, visionary, sees patterns
- The Ruler — authoritative, responsible, takes control
To change a character's archetype, edit SESSION.json and update the archetype field in the players array.
| Condition | Dev (DEV_INSTANT_RESPONSES=true) |
Production |
|---|---|---|
| Initial response (Round 1) | Instant | 5–10 min random delay |
| Follow-up response (Round 2) | 200ms | 30 min delay |
Round 1 fires for characters who were directly addressed or when nobody has responded yet. Round 2 fires for characters who were mentioned by another character in Round 1.
A character responds when:
- They were @-tagged in the GM message.
- Their name appears in the message body.
- No one has responded yet (someone should react).
- Another Round 1 character mentioned them by name.
Characters with status inactive or dead are skipped automatically.
Click Poke next to any player to force an immediate response, bypassing timing and the "should respond" heuristic.
- Create
backend/routes/myfeature.js:
import { Router } from 'express';
const router = Router();
router.post('/myfeature/action', async (req, res) => {
// do something
res.json({ ok: true });
});
export default router;- Mount it in
backend/index.js:
import myFeatureRoutes from './routes/myfeature.js';
app.use('/api', myFeatureRoutes);- If state changed, broadcast over SSE:
import { emit } from '../lib/eventBus.js';
emit('myEventType', { payload });- In
frontend/src/App.jsx, add a handler in theuseEffectSSE listener:
case 'myEventType':
setState(prev => ({ ...prev, ...data }));
break;- Create
frontend/src/components/MyComponent.jsx(andMyComponent.css). - Import into the parent (usually
App.jsxor a sidebar). - Use
fetch('/api/...')for API calls — Vite proxies them to the backend. - Pass data down via props; pass callbacks up via props.
All AI logic lives in backend/lib/agentRunner.js. Key things to change:
BEHAVIOR_INSTRUCTION— the core rules every character follows.buildSystem()— controls what context gets injected and in what order.- Response timing constants —
ROUND1_DELAY_MIN/MAX,ROUND2_DELAY. shouldRespond()— heuristic for whether a character chimes in.- Tool definitions — the dice tool is defined here; add more tools to give characters new capabilities.
- Create
campaigns/my-campaign/rules/new-system.mdwith the rules in markdown. - It's automatically loaded and injected into every AI prompt.
- Characters will follow those rules in responses.
- Write the document as a
.mdfile incampaigns/my-campaign/handouts/. - The server detects it immediately (no restart).
- The GM clicks Collect in the left sidebar to add it to the AI context.
Edit the archetype field in SESSION.json for the relevant player entry, using one of the 12 archetype names exactly as listed above. Takes effect on the next AI call.
ANTHROPIC_MODEL=claude-opus-4-7Or edit agentRunner.js directly to use different models per character type.
| File | What to touch it for |
|---|---|
backend/lib/agentRunner.js |
AI character behavior, prompts, timing, tools |
backend/routes/session.js |
Core game mechanics (messages, scenes, pins, handouts) |
backend/lib/archetypes.js |
Character voice templates |
backend/lib/campaign.js |
How campaign files are read and watched |
backend/lib/session.js |
SESSION.json read/write logic |
frontend/src/App.jsx |
All frontend state and SSE listener |
frontend/src/components/GMInput.jsx |
Dice, @-tagging, message sending |
frontend/src/components/Message.jsx |
Message rendering, edit, delete, pin |
shared/dice.js |
Dice notation parser — used by frontend display AND backend tool |
campaigns.config.json |
Register new campaigns here |
.env |
API keys and local config |
{
"messages": [
{
"id": "uuid",
"sender": "Elara Swift",
"senderSlug": "elara-swift",
"senderType": "pc",
"body": "I examine the lock closely.",
"timestamp": "2026-01-15T20:34:00.000Z",
"sceneId": "uuid",
"pinned": false,
"edited": false
}
],
"players": [
{
"characterSlug": "elara-swift",
"status": "active",
"archetype": "The Explorer",
"addedAt": "2026-01-15T19:00:00.000Z"
}
],
"scenes": [
{
"id": "uuid",
"title": "The Tavern",
"participants": [
{ "slug": "elara-swift", "type": "pc" },
{ "slug": "innkeeper", "type": "npc" }
],
"sessionZero": false,
"seats": [],
"createdAt": "2026-01-15T19:05:00.000Z"
}
],
"activeSceneId": "uuid",
"collectedHandouts": ["mysterious-letter"],
"pinnedMessageIds": ["uuid-of-pinned-message"]
}# Character Name
## Background
...
## Stats
...
## Personality
...The filename (minus .md) becomes the slug. The first # Heading is the display name.
| Method | Path | Description |
|---|---|---|
| GET | /api/campaign |
Campaign name, all characters/NPCs/rules/handouts |
| GET | /api/tags |
All character and NPC slugs and names (for autocomplete) |
| Method | Path | Description |
|---|---|---|
| GET | /api/session |
Full current session state |
| POST | /api/session/message |
Send GM message, triggers AI responses |
| PATCH | /api/session/message/:id |
Edit message body |
| DELETE | /api/session/message/:id |
Delete message |
| POST | /api/session/player |
Add a player by characterSlug |
| DELETE | /api/session/player/:slug |
Remove a player |
| PATCH | /api/session/player/:slug/status |
Update player status |
| POST | /api/session/scene |
Create new scene |
| POST | /api/session/scene/:id/activate |
Switch active scene |
| POST | /api/session/scene/:id/participant |
Add participant to scene |
| POST | /api/session/pin/:messageId |
Toggle pin on a message |
| POST | /api/session/handout/:slug/collect |
Collect a handout |
| Method | Path | Description |
|---|---|---|
| POST | /api/agent/poke/:slug |
Force character to respond immediately |
Connect to GET /api/events to receive a stream of events. Each event has a type and a JSON data payload. The connection sends a heartbeat every 25 seconds.
AI responses never arrive
- Check that
ANTHROPIC_API_KEYis set and valid. - Check the backend terminal for API errors.
- Try enabling
DEV_INSTANT_RESPONSES=trueto rule out timing issues. - Make sure the player status is active (not inactive or dead).
"Cannot connect to server" in the frontend
- Is the backend running? Check for errors in the backend terminal.
- Is the frontend proxying correctly? Check
frontend/vite.config.js— target should behttp://localhost:3001.
Character doesn't appear in roster
- The
.mdfile must be incampaigns/your-campaign/characters/. - The campaign must be registered in
campaigns.config.json. - The
DEFAULT_CAMPAIGNenv var must match the slug.
SESSION.json is corrupted
- The backend serializes writes to prevent conflicts, but a hard crash mid-write can corrupt it.
- Open
SESSION.json, check for truncation or invalid JSON. - Restore from git, or rebuild the minimal structure (see Local Setup).
- The session can also be reset by replacing it with the minimal template.
Handout not appearing after dropping into folder
- The file watcher (
chokidar) may take a second or two. - Verify the file is in
handouts/(not a subdirectory). - Restart the backend if it still doesn't appear.
AI isn't following campaign rules
- Confirm rules files are in
rules/inside the campaign directory. - Check
MEMORY.mdfor any conflicting instructions. - Rules are injected once per prompt — very long rule documents may affect model attention. Consider splitting into focused files.
Messages appear in wrong scene
- Messages are tagged with the active scene at send time.
- If you switched scenes and then re-sent, the message belongs to the new scene.
- Edit
SESSION.jsondirectly to correct thesceneIdon a message.
Corporate network / proxy errors
- Set
NODE_EXTRA_CA_CERTSto the path of your corporate CA certificate (.pemformat).