Skip to main content

fim2

A self-hosted, AI-augmented virtual tabletop for D&D 5e: a Next.js and Socket.io app per campaign, provisioned by a NestJS control plane.

Status
Active, private repository (write-up only)
First commit
Latest commit
  • Next.js 14
  • TypeScript
  • Socket.io
  • Prisma
  • PostgreSQL
  • NextAuth
  • NestJS
  • Docker
  • Nginx
Source: private repository
fim2 architecture Browsers reach a reverse proxy that routes each campaign to its own container. Each container runs a custom Node server hosting the Next.js app and a Socket.io relay, backed by its own Postgres database through Prisma, and calls external AI providers. A separate NestJS control plane handles identity and provisioning: it creates the containers through the Docker Engine API and writes the proxy routing. BrowsersGM, players, guests Reverse proxyroutes by campaign Control planeNestJS: identity, provisioning routing One container per campaign Custom Node server: Next.js 14 app + API routes Socket.io relayper-event authorization Domain logicvalidation, permissions, AI PostgreSQL via Prisma (this campaign only) creates containers (Docker Engine API) AI providersexternal LLM APIs
Architecture overview: Architecture diagram. Browsers reach a reverse proxy that routes by campaign to a dedicated container. Inside each container a custom Node server runs the Next.js app, a Socket.io relay and domain logic, backed by that campaign's own PostgreSQL database via Prisma, and calls external AI providers. A separate NestJS control plane handles identity and provisioning, creating containers through the Docker Engine API and updating the proxy routing.

This repository is private, so this is a write-up of the architecture and decisions rather than a code tour.

The problem

Hosted virtual tabletops are convenient but I wanted to own the data and the table: maps, tokens, combat, chat and dice for my own D&D 5e campaigns, with AI help for the tedious parts (generating characters, items and lore, rules questions) layered on top of an otherwise ordinary VTT.

Key design decisions

One container and one database per campaign. A NestJS control plane provisions each campaign as its own Docker container with its own Postgres database behind a reverse proxy that routes by subdomain. This gives strong isolation and makes one campaign’s failure or upgrade independent of the others. The trade-off is that anything spanning campaigns, such as a shared asset library or carrying a character between campaigns, needs inter-container coordination and is awkward. That limit is documented in the project, and it is the main thing a later rewrite would revisit.

A custom server so HTTP and WebSockets share one process. The app wraps Next.js 14’s request handler in a custom Node server and attaches Socket.io to the same HTTP server. Fast gameplay events (token drags, measurements, previews) are rebroadcast immediately without a database write; committed actions go through API routes, write via Prisma, and then emit an authoritative event. Authorization runs per socket event.

Postgres is the source of truth. There is no in-memory session actor. That keeps the relay simple, but a second independent client (a dedicated player app) would expose limits in the model, so a server-authoritative design is recorded as a deferred decision with the trigger that would force it. A batch of real sync bugs was found and fixed along the way (stale client cache overwriting live state, reconnect recovery, drag-in-progress tokens being clobbered).

Roles and guests. Owner, GM, player and observer roles, plus signed-invite guest play, all feed the same capability checks. Sign-in delegates to the control plane, so a campaign container cannot start in production without it.

AI is a layer, not the product. Providers are pluggable, and an MCP tool surface with GM arbitration for players’ natural-language intents is in progress.

Stack

Next.js 14 (App Router), React 18, TypeScript, Tailwind, Socket.io, Prisma and PostgreSQL, NextAuth, NestJS, Docker and Nginx.

Outcome and what I would change

About 2,300 commits between 2025-09-08 and 2026-10-17. An earlier NestJS and React stack was superseded by the Next.js app and is kept read-only, with a repository check that fails if a branch touches the legacy code without a stated reason. The honest weakness is the dependency on that legacy control plane for identity, and the per-campaign tenancy model. If I did it again I would collapse tenancy into one multi-campaign deployment before building features that need to cross campaigns.