familiar.systems familiar.systems
(Updated on May 12, 2026 )

The Systems of familiar.systems

A technical tour of our preliminary open-source per-campaign/per-world architecture


Picture a Tuesday night. You haven’t touched your campaign in two weeks: real life, a holiday, overtime at work. Your Blades crew is arriving in twenty minutes for the next phase of the Crow’s Foot job. You open the browser tab, click into the campaign, and start scribbling last-minute notes for the warehouse the crew is going to hit. One of your players is on the train, opens her character on her phone, and updates her playbook because she remembered she swapped out a stress for an item last session. The page she’s editing isn’t a page you’ve opened in months.

This post is about what happens between “I want to play tonight” and the cursor blinking inside a paragraph that someone else is also editing. It’s about the systems that power familiar.systems.

A GM plans her game, raven familiar at her side.

Your campaign/world is a database

Whether you’re running a one-off module like Curse of Strahd or a living world like the 7th job a crew has run in Duskvol in Blades of the Dark, your world is a single, self-contained, mostly-cold dataset.

  • A campaign is a few thousand pages at most: NPCs, locations, factions, items, journal entries. Plus the embeddings to search across them, and the relationship edges that tie them together. None of these are large; the whole campaign tends to fit comfortably in tens of megabytes.
  • Campaigns don’t query each other. A goblin in your friend’s Daggerheart campaign has nothing to say about a goblin in my Mines of Phandelver short.
  • Most campaigns are dormant most of the time. They wake up for a few hours one evening a week, and even during a session only a handful of pages are touched.
  • The users of a campaign are people who already trust each other. Usually about 5 to 6, unless it’s West Marches.

Given all of that, we treat each campaign as one SQLite file. Vector search lives in the same file via sqlite-vec. Migrations are handled by sea-orm, which beat libSQL on ergonomics in the end (we evaluated libSQL first but we wanted an ORM). The full reasoning for one file per campaign sits in the SQLite-over-Postgres decision doc.

Opening a campaign

There are two services.

The platform is the directory. It knows about users, campaigns, and which small server is currently holding each campaign. It is the only place in the system that holds anything resembling a global view.

The campaign servers are the small servers. Each holds one or more checked-out campaigns on local NVMe. They run the editing actors, take WebSocket connections, and stream collaborative edits back out.

Here’s what happens when you open a campaign:

  1. The SPA in your browser calls POST /api/campaigns/:id/checkout on the platform.
  2. The platform consults its routing table: campaign id to server.
  3. Cold start. If no server has this campaign open, the platform picks the least-loaded campaign server, asks it to acquire a lease, and the server downloads the SQLite file from object storage to its local volume.
  4. Hot start. If a server already has it, the platform returns that assignment immediately.
  5. Either way, the SPA receives a shard-agnostic URL and opens a WebSocket against it.
FIG. 01 COLD START 8 steps first open of the evening
Cold start sequence SPA POSTs to Platform; Platform misses its routing table; tells a Campaign Server to acquire a lease; Server downloads the campaign database from Object Storage; Server reports ready to Platform; Platform returns the WebSocket URL and token to the SPA; SPA opens a WebSocket against the Campaign Server. SPA PLATFORM STORAGE SERVER 01 POST /checkout 02 routing table miss 03 acquire lease 04 download campaign.db 05 bytes 06 ready 07 { ws_url, token } 08 open WebSocket
// 8 messages, 1 round-trip to object storage. Worst case.
FIG. 02 HOT START 4 steps a server already has it
Hot start sequence SPA POSTs to Platform; Platform finds the campaign in its routing table; returns the WebSocket URL and token to the SPA; SPA opens a WebSocket against the Campaign Server. Storage and Server lanes are quiet — the entire middle of the cold path is skipped. SPA PLATFORM STORAGE SERVER 01 POST /checkout 02 routing table HIT 03 { ws_url, token } 04 open WebSocket
// typical path. Most opens hit this.
Same lanes, same ledger. The entire middle of the cold path simply doesn't run on the hot path. Much lower latency.

The lease keeps two servers from owning the same campaign at the same time. A server holds the lease as long as the campaign is open and active; it releases it only after writing back to object storage. The full shutdown protocol is in the deployment ADR.

Object storage is cold storage; local NVMe is the hot path. Once the file is on the campaign server’s volume, every read and write hits a local disk. The same single-binary topology runs in dev, in PR previews, and in production.

FIG. 03 PLATFORM ROUTING TABLE 5 entries live system state
campaign id server status
shadows-of-doskvol 01HG7K2M… campaign-2 [just acquired]
the-crooked-coast 01HG6X9P… campaign-1 [active]
curse-of-strahd 01HG8B1Q… [dormant]
hollows-of-phandelver 01HG9D4R… [dormant]
daggerheart-westmarches 01HGA2F7… [dormant]
// active ↔ pinned to a server. dormant ↔ pinned to nothing.
The platform's only globally-stateful job: this table. The flagged row is the campaign you just opened above.

Editing a campaign

Once your browser has a WebSocket open against a campaign server, the rest of the system is built around one idea: every open page is its own thread.

A half-paragraph CRDT primer for anyone who hasn’t met them yet. A CRDT is a data structure where two people can edit the same paragraph at the same time and converge to the same result, without locking, retries, or operational transforms. Zed wrote a much better explanation than I could and it’s worth twenty minutes if you’ve never built on one.

We use Loro, a Rust CRDT library, with the loro-prosemirror binding so it slots into our editor, TipTap on ProseMirror.

Each open page is one kameo actor on the campaign server. The actor owns the page’s LoroDoc and has its own inbox. Two people on Page A and two people on Page B never contend; they’re four people across two independent threads. Each thread does its own work at its own pace, and a slow page never blocks a fast one because they aren’t sharing a runtime resource.

graph LR
    B1[Browser 1] -. WebSocket .- S
    B2[Browser 2] -. WebSocket .- S
    subgraph S [Campaign Server]
        T1[Page A actor]
        T2[Page B actor]
        TOC[ToC actor]
    end

Multiple rooms ride a single socket. Each browser opens one WebSocket against the campaign server and joins rooms by id; the Loro protocol multiplexes them on the wire. There is also a top-level “table of contents” actor for the campaign’s organizational structure that everyone subscribes to so the sidebar stays live.

When the last subscriber to a page leaves and an idle timer fires, the actor snapshots its LoroDoc into the campaign’s SQLite file and evicts. The next visitor reconstructs it. CRDT state is transient; the relational data on disk is the canonical record. Blob-free at rest.

stateDiagram-v2
    [*] --> Spawning: first request
    Spawning --> Live: restore from SQLite
    Live --> Live: edits + broadcasts
    Live --> Idle: no subscribers
    Idle --> Live: new subscriber
    Idle --> Snapshotting: idle timeout
    Snapshotting --> [*]: writeback + evict

TTRPGs cleanly fit into the per-page actor model. Most pages are quiet, a few are bursty during sessions, and most campaigns can share a single small server because nothing is paying for state it isn’t using.

The hot path is small. A browser opens its WebSocket and the connection’s read task keeps a tiny HashMap<RoomId, RoomHandle>. The first message for a room (a JoinRequest) goes through the supervisor, which spawns or hands back the page actor. Every subsequent DocUpdate goes straight to the actor without touching the supervisor. The supervisor is for lifecycle, not for traffic.

The AI is a guest, not a co-author

Your familiar is an AI agent. All of the editing infrastructure above is also where suggestions land, but with one important rule: the AI is a guest, not a co-author.

The AI doesn’t speak the CRDT protocol. It calls tools. A serialization compiler turns those tool calls (suggest_replace, create_page, propose_relationship) into “suggestion marks” on the relevant block UUIDs in a page’s LoroDoc. The original content stays put; the suggestion sits next to it, scoped to the conversation that produced it.

sequenceDiagram
    participant Agent as AI Agent
    participant Compiler
    participant ThingActor
    participant Editor as TipTap Editor

    Agent->>Compiler: suggest_replace(blocks, new_content)
    Compiler->>ThingActor: CompiledSuggestion
    ThingActor->>ThingActor: apply suggestion mark
    ThingActor-->>Editor: CRDT broadcast
    Editor->>Editor: render mark, lock blocks

A block under a pending suggestion is read-only in the editor until the GM accepts or rejects. This is the design’s answer to the AI-overwrites-your-typing problem: the AI literally cannot win a race against you, because the moment it has a suggestion, your block is locked until you decide.

The Warehouse on Drake Street

Duskvol / Crow's Foot / Drake Street
Edited 2 minutes ago
Three storeys of soot-blackened brick. The sign over the gate still reads Saggek & Sons, Cooperage, but nobody has rolled a barrel out of it in a generation.
The Bluecoats walk past it twice a night. They never look up.
Locked Inside, the ground floor is empty.
Proposal · Content Rewrite Awaiting your decision

Inside, the ground floor is mostly empty: a few collapsed crates against the back wall, a tarpaulin draped over something the size of a small boat, and the smell of old whale oil that won't quite leave.

Suggested by your familiar after the Session 3 review · 2 minutes ago
Locked But the trapdoor at the back, half-hidden under a bolt of canvas, is oiled. Recently.
Proposal · Visibility Reveal Awaiting your decision
GM only Players

Found in Session 3 · 0:42:27

Suggested by your familiar after the Session 3 review · just now
Editor mockup: a content rewrite and a visibility change, both pending the GM's decision.

Multiple agents can suggest simultaneously, even on the same blocks. The GM picks. When one is accepted, the server’s classifier walks any overlapping suggestions and marks them invalidated; the editor cleans up automatically.

stateDiagram-v2
    [*] --> Pending: AI tool call → compiler → mark
    Pending --> Accepted: GM accepts
    Pending --> Rejected: GM rejects
    Pending --> Invalidated: overlap accepted elsewhere
    Accepted --> [*]
    Rejected --> [*]
    Invalidated --> [*]

The slogan from the vision doc is “AI proposes, the GM disposes.” This is what it means in the editor. The full mechanism (compiler, suggestion lifecycle, conversation scoping) lives in the actor domain design.

Big tasks fan out. A single conversation can spawn subagents. Each is its own actor with its own context window working its own slice of the problem in parallel. The GM only ever chats with the primary familiar but every subagent’s tool calls and findings show up in the conversation. That way, the GM can correct bad results from a subagent that’s wandered off-track.

What this gives us

A few things drop out of this design:

  • Campaign-as-file means GDPR delete is rm. Branch deploys are cp. Every PR preview environment runs against real campaign data instead of fixtures. Contributing to branch deployments is opt-in. If you’re interested in helping out development, please consider opting-in on your campaigns.
  • One owning server per campaign means there is no Redis, no consensus protocol, no distributed transactions. The router is a small table on the platform; that is the entire coordination story.
  • A small stateless platform stays up while a campaign server restarts, so login and campaign discovery don’t go down with the editor. Active editing on a particular campaign hiccups for a few seconds during a deploy; nothing else does.
  • Object storage as the cold backing means we can grow or shrink the campaign-server fleet without data migration. Rebalancing a campaign is “writeback, update routing table, re-checkout elsewhere.”

Third time’s the charm

This isn’t a design we’re trying out. We’ve built it twice already.

The first time was in TypeScript on Hocuspocus, Yjs, Hono, and Node. It validated the principles (campaign-as-file, object storage as cold backing, per-page room, “AI proposes, the GM disposes”) but the Node.js event loop forced architectural workarounds the design didn’t ask for: two read paths, two write paths, manual memory pressure management. The runtime kept showing up in the design. The remanants of that experiment are available on github.

The second time was a Rust spike on kameo, Loro, and TipTap. Same picture, none of the workarounds. Two browsers editing the same page, suggestion marks blocking edits, accept and reject with cascade invalidation when overlapping suggestions collide, all of it end to end with passing Rust unit, TipTap unit, and Playwright e2e tests.

The third pass is what’s being worked on now in apps/campaign: the same architecture, but multi-tenant, with real logging, sea-orm migrations, and the full production app structure around it. Across all three passes, the principles held but the implementation got cleaner each time.

The full design lives in the campaign collaboration architecture ADR, and the validation framing for the suggestion model is in the Loro/TipTap spike plan.