Sign inSign up

darktrees/virtual-tabletop

By darktrees

•Updated 3 days ago

Self-hosted multiplayer tabletop: a rules engine keeps score, a model narrates.

Image
0

673

darktrees/virtual-tabletop repository overview

⁠virtual-tabletop

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.

⁠What it does

  • A shared map with fog of war. Vision is shadowcast from each character, so a creature behind a wall is not drawn, and is not sent to the players' browsers either.
  • The 2024 rules (SRD 5.2) for characters, combat, spells and conditions, with guided character creation and levelling.
  • A referee that narrates outcomes the engine has decided, keeps a rolling summary of the evening, and asks the host before it starts a fight.
  • Host controls: undo, set hit points, open scenes, move the clock, pin facts the table should remember.
  • Adventures as data. Two ship with the image; more can be imported from a published JSON schema.
  • One event log per room, replayable and undoable, with every roll seeded.

⁠Running it

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 }
⁠Before the first start

Create the four secret files. Credentials are read from files, never from environment variables, so they stay out of docker inspect.

FileWhat it isHow to make one
master_keyEncryption key for data at restopenssl rand -hex 32
index_pepperPepper for the email blind indexopenssl rand -hex 32
smtp2go_keyMail API key, to send login codesfrom your SMTP2GO account
db_passwordThe Postgres passwordopenssl 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.

⁠Configuration
VariableRequiredWhat it's for
PUBLIC_URLyesThe origin players use. Must be https except on localhost; login codes and the socket check it.
APP_NAMEyesWhat this installation calls itself, in the title bar and on login mail.
MAIL_FROMyesThe sender of login codes, at a domain your mail provider has verified.
DB_HOSTyesThe Postgres host. The password comes from the db_password secret, never a URL.
DB_NAME, DB_USERyesThe database and its user.
DB_PORTnoDefaults to 5432.
TRUST_PROXYnoThe one address whose X-Forwarded-For is believed. Unset trusts nobody.
MODEL_SPEND_CAP_USDnoPer-session ceiling on model spend, in dollars. The host can raise it mid-evening. Unset: no cap.
PORT, HOSTnoWhere to listen. Defaults to 0.0.0.0:8080.
ASSET_ROOTnoWhere uploaded maps and portraits are kept. Defaults to /data/assets.
SECRETS_DIRnoWhere 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.

⁠Data and attribution

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.

SourceUsed forTerms
System Reference Document 5.2⁠, Wizards of the CoastClasses, species, backgrounds, feats, spells and their rulesCC-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 timeNot contacted at runtime; nothing beyond the SRD's own text ships.
Spectral⁠, IBM Plex⁠The interface's typeSIL Open Font License 1.1⁠. Ships in the image.
Anthropic⁠, or any OpenAI-compatible endpointNarration and summaries, if a host adds a keyEach host's own account and that provider's terms. Nothing ships; called at runtime only.
SMTP2GO⁠Sending login codesThe 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.

⁠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.

⁠Source

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.

Tag summary

Content type

Image

Digest

sha256:219f1ee96…

Size

78.4 MB

Last updated

3 days ago

docker pull darktrees/virtual-tabletop