Self-hosted multiplayer tabletop: a rules engine keeps score, a model narrates.
673
A self-hosted, multiplayer tabletop for fifth-edition fantasy games, refereed by a language model you bring your own key for. Players join from any browser; the host runs the evening from the same screen.
The idea it is built around: the rules engine is authoritative, and the model never holds state and never does arithmetic. The engine rolls the dice, tracks hit points and decides what is legal. The model is handed an outcome that has already been decided and asked to describe it. A table with no model configured still plays, with every number right and nothing narrated.
It needs Postgres 17 beside it, and a reverse proxy in front for TLS.
services:
app:
image: docker.io/darktrees/virtual-tabletop:latest
restart: unless-stopped
ports:
- '127.0.0.1:8080:8080'
networks: [edge, backing]
environment:
DB_HOST: db
DB_NAME: vtt
DB_USER: vtt
PUBLIC_URL: https://table.example.com
APP_NAME: The Table
MAIL_FROM: [email protected]
MODEL_SPEND_CAP_USD: 5
secrets: [master_key, index_pepper, smtp2go_key, db_password]
volumes:
- ./data:/data
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-alpine
restart: unless-stopped
networks: [backing]
environment:
POSTGRES_DB: vtt
POSTGRES_USER: vtt
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets: [db_password]
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U vtt -d vtt']
interval: 10s
volumes:
- ./data-db:/var/lib/postgresql/data
secrets:
master_key: { file: ./secrets/master_key }
index_pepper: { file: ./secrets/index_pepper }
smtp2go_key: { file: ./secrets/smtp2go_key }
db_password: { file: ./secrets/db_password }
networks:
edge: {}
backing: { internal: true }
Create the four secret files. Credentials are read from files, never from
environment variables, so they stay out of docker inspect.
| File | What it is | How to make one |
|---|---|---|
master_key | Encryption key for data at rest | openssl rand -hex 32 |
index_pepper | Pepper for the email blind index | openssl rand -hex 32 |
smtp2go_key | Mail API key, to send login codes | from your SMTP2GO account |
db_password | The Postgres password | openssl rand -hex 24 |
Back up master_key and index_pepper somewhere other than the database:
losing the first loses every stored email address and API key, and losing the
second means nobody can sign in again.
Then start it, bring the schema up, and let the first person in:
docker compose up -d
docker compose exec app node --import tsx src/db/cli.ts
docker compose exec app node --import tsx src/auth/cli.ts [email protected] --note "the host"
The container does not migrate itself. Until the schema is current it answers
503 to everything and reports itself unhealthy, naming the missing
migrations. Sign-up is closed by default, so the last line is the only way in
on a fresh install. The first account to sign in becomes the admin, and from
then on new players are added from the Players dialog in the app.
Exposing it. Sign-in is by a one-time code sent to an allowlisted address,
sessions are cookies on the origin in PUBLIC_URL, and the room socket checks
that origin before it opens. Put it behind a TLS-terminating proxy and set
TRUST_PROXY to that proxy's address. Postgres should be on a network with no
published port, as above.
| Variable | Required | What it's for |
|---|---|---|
PUBLIC_URL | yes | The origin players use. Must be https except on localhost; login codes and the socket check it. |
APP_NAME | yes | What this installation calls itself, in the title bar and on login mail. |
MAIL_FROM | yes | The sender of login codes, at a domain your mail provider has verified. |
DB_HOST | yes | The Postgres host. The password comes from the db_password secret, never a URL. |
DB_NAME, DB_USER | yes | The database and its user. |
DB_PORT | no | Defaults to 5432. |
TRUST_PROXY | no | The one address whose X-Forwarded-For is believed. Unset trusts nobody. |
MODEL_SPEND_CAP_USD | no | Per-session ceiling on model spend, in dollars. The host can raise it mid-evening. Unset: no cap. |
PORT, HOST | no | Where to listen. Defaults to 0.0.0.0:8080. |
ASSET_ROOT | no | Where uploaded maps and portraits are kept. Defaults to /data/assets. |
SECRETS_DIR | no | Where the secret files are. Defaults to /run/secrets. |
Model keys are not configuration. Each host adds their own from inside the
app; they are encrypted at rest with master_key and decrypted only for the
moment of a request.
This project's licence covers its own code. The rules content and fonts below are other people's work under their own terms. Whoever runs the image supplies their own model and mail accounts and agrees to those providers' terms themselves.
| Source | Used for | Terms |
|---|---|---|
| System Reference Document 5.2, Wizards of the Coast | Classes, species, backgrounds, feats, spells and their rules | CC-BY-4.0. Ships in the image; the attribution notice is in the app's About panel. |
| Open5e | How the SRD text above was fetched, at development time | Not contacted at runtime; nothing beyond the SRD's own text ships. |
| Spectral, IBM Plex | The interface's type | SIL Open Font License 1.1. Ships in the image. |
| Anthropic, or any OpenAI-compatible endpoint | Narration and summaries, if a host adds a key | Each host's own account and that provider's terms. Nothing ships; called at runtime only. |
| SMTP2GO | Sending login codes | The operator's own account and SMTP2GO's terms. Nothing ships. |
The two bundled adventures, The Hollow Bell and The Salt Lamp, are original to this project and under its licence.
virtual-tabletop is source-available under the
PolyForm Noncommercial License 1.0.0.
Personal, hobby, research, educational and charity use are all welcome.
Commercial use is not permitted without a separate licence. The full terms
are at /app/LICENSE in the image.
The code lives on a private forge. It's available on request for non-commercial use; get in touch through the contact link on the darktrees Docker Hub profile. To ask about commercial use, use the same link.
Content type
Image
Digest
sha256:219f1ee96…
Size
78.4 MB
Last updated
3 days ago
docker pull darktrees/virtual-tabletop