A system-agnostic 2D virtual tabletop that runs as one Docker stack, hostable two
ways: self-host it on your own machine and let remote players in via a tunnel
(ngrok or Tailscale), or deploy it to a DigitalOcean droplet where Caddy provides
automatic TLS. Play is remote in both. Voice and video are handled by external
tools (Discord, etc.). Day-to-day development is native (no Docker) for fast
iteration; Docker is only for actually hosting a session. See
docs/10-cloud-deployment.md for the full deployment guide (all three modes).
Stack: React + PixiJS (frontend), Node + Express + Socket.io (backend), PostgreSQL (Dockerized in the deployment stack; local for dev), TypeScript throughout. It is an npm-workspaces monorepo with a shared package that holds the WebSocket contract as a single source of truth.
Features implemented:
- Real-time token sync over Socket.io using tiny JSON deltas.
- Manual click-to-reveal fog of war (square and hex grids), plus a GM conceal tool to paint fog back.
- Drag-and-drop tokens that snap to the grid.
- Server-side anti-cheat: hidden tokens on unrevealed cells are stripped from players' payloads entirely.
- System-agnostic character sheets stored as JSONB.
- GM toolkit Phase 1: a per-campaign map library, GM-managed live map tabs, and cross-map token relocation (players auto-load whichever map their token is on).
shared/ @vtt/shared - wire contract types + square/hex cell math
backend/ Express + Socket.io + raw pg; db/schema.sql, db/seed.sql
frontend/ Vite + React + TanStack Router + PixiJS
docs/ design + as-built docs (01 architecture ... 10 deployment ... 11 GM toolkit)
Install these before anything else. Versions in parentheses are what this project was developed against; newer patch/minor releases are fine.
| Tool | Minimum | Notes |
|---|---|---|
| Git | any | to obtain the code |
| Node.js + npm | Node 20+ (dev on 24) | npm ships with Node |
| PostgreSQL | 14+ (dev on 18) | includes the psql and createdb CLIs |
Optional:
| Tool | Purpose |
|---|---|
| Playwright browsers | run the browser end-to-end checks (npx playwright install chromium) |
| Docker Desktop | only needed when you're ready to host (section 8) — not for day-to-day dev |
macOS / Linux (recommended: nvm)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash
# restart your shell, then:
nvm install 20
nvm use 20macOS (Homebrew alternative): brew install node
Windows: install the LTS build from https://nodejs.org, or
winget install OpenJS.NodeJS.LTS
Verify:
node --version # v20 or newer
npm --versionmacOS (Homebrew)
brew install postgresql@18
brew services start postgresql@18
# ensure the CLIs are on PATH (Apple Silicon path shown):
echo 'export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH"' >> ~/.zshrc
exec zshOn Homebrew, PostgreSQL creates a superuser role equal to your macOS username, with trust authentication for local connections (no password).
Ubuntu / Debian
sudo apt update
sudo apt install postgresql postgresql-client
sudo systemctl enable --now postgresqlThe default superuser is postgres (access via sudo -u postgres ...).
Windows
Install via winget install PostgreSQL.PostgreSQL.16 or the EDB installer from
https://www.postgresql.org/download/windows/. The installer creates the
postgres superuser and asks you to set its password. Use the bundled
"SQL Shell (psql)" or add the PostgreSQL bin folder to your PATH.
Verify:
psql --version
pg_isready # should say "accepting connections"git clone <your-repo-url> vtt # or copy the project folder
cd vtt
npm install # hydrates all three workspacesBuild the shared package once so the backend and frontend can resolve its types
(required on a fresh checkout, since shared/dist/ is generated):
npm run build -w sharedMake sure the PostgreSQL server is running (pg_isready), then:
macOS (Homebrew) / any setup where your OS user is a Postgres superuser
createdb vtt
psql -d vtt -f backend/db/schema.sql
psql -d vtt -f backend/db/seed.sql # optional demo dataLinux (using the postgres superuser)
sudo -u postgres createdb vtt
sudo -u postgres psql -d vtt -f backend/db/schema.sql
sudo -u postgres psql -d vtt -f backend/db/seed.sqlWindows (SQL Shell / psql, as postgres)
createdb -U postgres vtt
psql -U postgres -d vtt -f backend/db/schema.sql
psql -U postgres -d vtt -f backend/db/seed.sqlThe schema creates the users, campaigns, character_sheets, game_maps,
campaign_live_maps, and tokens tables (see docs/02-database-schema.md).
The seed adds a demo campaign, map, and tokens (see the Demo section below).
Alternative (npm scripts): if you export DATABASE_URL in your shell first,
you can use the bundled scripts instead of the per-OS psql commands above:
export DATABASE_URL=postgresql://YOUR_USERNAME@localhost:5432/vtt
npm run db:setup # runs db:init (schema) then db:seed (demo data)
# or individually:
npm run db:init # schema only
npm run db:seed # demo data onlyBoth the schema and the seed are idempotent, so re-running is safe. After a
schema change, re-apply with npm run db:reset (drops + recreates, then
re-seeds).
Important: the
db:init,db:seed,db:setup, anddb:resetscripts readDATABASE_URLfrom your shell environment only. They do not read the.envfile you create in step 5 (that.envis loaded by the backend at runtime, not by these psql scripts). So you mustexport DATABASE_URL=...first, or just use the per-OSpsql -d vtt -f ...commands above, which need no environment variable at all.
Copy the template and edit it to match your database:
cp .env.example .env.env is machine-specific; do not carry another machine's .env over. Set
DATABASE_URL for your setup:
- macOS Homebrew (role = your username, no password):
DATABASE_URL=postgresql://YOUR_USERNAME@localhost:5432/vtt - Linux / Windows (postgres role with the password you set):
DATABASE_URL=postgresql://postgres:YOUR_PASSWORD@localhost:5432/vtt
Other variables:
PORT=4000 # backend HTTP + Socket.io port
ASSET_DIR=./uploads # where map images are served from
CORS_ORIGINS=http://localhost:5173 # origins allowed to connect
VITE_SERVER_URL=http://localhost:4000 # backend URL the browser uses
CORS_ORIGINS accepts a comma-separated list. The backend loads this root
.env via backend/src/env.ts; the frontend reads VITE_* from it via Vite
envDir. (This same .env also holds the POSTGRES_* variables Docker
Compose reads in section 8 — they're inert for native dev, so it's safe to
fill in both sets now if you know you'll deploy later.)
npm run dev # starts backend (:4000) and frontend (:5173) together, with hot reload- Frontend: http://localhost:5173
- Backend health: http://localhost:4000/health should return
{"ok":true,"db":true}
Stop with Ctrl-C. This is the day-to-day workflow — Docker is not involved.
Open http://localhost:5173. You land on a login screen; sign in with a seeded account (or register a new one), then the lobby lists your campaigns. Click Enter on the Demo Campaign to open the map.
Seeded accounts (from backend/db/seed.sql):
| Display name | PIN | Role in Demo Campaign |
|---|---|---|
Game Master |
1234 |
GM |
Player One |
4321 |
player |
In the map, the toolbar (GM) / HUD (player) provides:
- Drag tokens - grab a token and drop it; it snaps to the cell center. The GM can move any token; a player only their own.
- Fog tool Reveal / Conceal (GM only) - click an empty cell to uncover it or paint fog back over it. Concealing re-hides any hidden token on that cell from players.
- Live map tabs (GM only) - the tab bar switches between the GM's live maps; Map Library adds an existing library map as a tab (or uploads a new one straight into the live set); the Players panel relocates a player by dragging their row onto a tab.
Role is server-derived from the campaign (creator = GM), not a client toggle.
The hidden "Lurking Orc" on unrevealed cell 10,5 is visible to the GM but
stripped from players until the GM reveals that cell (the anti-cheat). The Demo
Campaign's join code is DEMO42 (used when a second account joins via the lobby).
Everything above is native, local, and disposable — good for building the
app, not for actually running a session with other people. When you're ready
to host, Docker is the packaging: same image, same docker-compose.yml,
three modes. Full walkthrough in docs/10-cloud-deployment.md; the essentials:
Requirements: Docker Desktop
(includes the Compose plugin) — no separate Node/Postgres install needed for
this path, Docker builds everything. Confirm with docker compose version.
In your .env (the same file from section 5), set a POSTGRES_PASSWORD:
POSTGRES_USER=vtt
POSTGRES_PASSWORD=<a real value, not the placeholder>
POSTGRES_DB=vtt
docker compose up -d --build
curl http://localhost:4000/health # {"ok":true,"db":true}Builds the image and starts postgres-db + vtt-app (both show up in Docker
Desktop), serving the built SPA + API + Socket.io same-origin on :4000 — no
:5173, no CORS config, nothing else to wire up. Auto-applies
backend/db/schema.sql + backend/db/seed.sql on the database's first init.
Open http://localhost:4000 and log in as above to confirm it works.
docker compose down # stop (keeps the pgdata/uploads volumes)
docker compose down -v # stop AND wipe the DB — see the schema note below
docker compose logs -f vtt-app # tail logs(npm run deploy:local / npm run deploy:down are shorthands for the two
up/down commands above, if you have Node installed — not required.)
Schema changes: the database only runs its init scripts once, on the first boot of a fresh
pgdatavolume. Ifbackend/db/schema.sqlchanges after your volume already exists (e.g. you pulled new commits, or built the image before a schema change likecampaign_live_maps), the running container's schema is stale until youdocker compose down -v && docker compose up -d --build. This wipes all data in the containerized Postgres — fine for local/demo use, back up first if you've put real campaign data in it.
Keep the stack from 8b running, then expose port 4000 from your machine:
- ngrok (simplest, no player install):
ngrok http 4000, share thehttps://…ngrok-free.appURL. Free tier shows a one-time click-through interstitial; a new URL each run. - Tailscale Funnel (stable URL, no interstitial):
tailscale funnel 4000after enabling Funnel for your tailnet, players use yourhttps://<machine>.<tailnet>.ts.netURL. - Tailscale Serve (most private):
tailscale serve 4000, only reachable by people you invite into your tailnet. - Same-LAN players: set
APP_BIND=0.0.0.0in.envand restart (docker compose up -d --build), players openhttp://YOUR_LAN_IP:4000.
Because the Docker build is same-origin, players just open the URL — nothing
to rebuild or reconfigure. See docs/10-cloud-deployment.md for the full
walkthrough of each tunnel option.
Security note: login is display name + PIN (bcrypt) with per-campaign join
codes, but the PIN space is small (see the threat model in
docs/09-login-and-identity.md). Keep any public tunnel within a trusted group.
npm run deploy:cloud (docker compose --profile edge up -d --build) adds
Caddy for automatic Let's Encrypt TLS on a real domain. Full copy-paste guide
(droplet sizing, DNS, firewall, first launch, backups) is in
docs/10-cloud-deployment.md.
- No container shows up in Docker Desktop: you haven't run
docker compose up -d --buildyet — day-to-day dev (sections 2-7) never touches Docker at all, by design. POSTGRES_PASSWORDerror ondocker compose up:.envdoesn't have it set (see 8a).- Port already in use (4000): a native
npm run devis still running, or a leftover container.docker ps/ stop the other process first. - Demo Map's thumbnail/background is a broken image (404 on
/assets/demo-map.png): known gap —backend/db/seed.sqlreferences that path but no such file ships in the repo or theuploadsvolume. Harmless (grid/tokens/fog all work); upload a real map to replace it, or ignore it.
npm run typecheck # tsc across shared, backend, frontend
createdb vtt_test # one-time test DB
npx playwright install chromium # one-time, for the e2e
npm test # Vitest (unit + integration) + Playwright (e2e)npm test uses ports :4000 and :5173, so stop any running dev/Docker app first.
See docs/06-verification.md for the full suite layout and what it asserts.
Run from the repo root:
| Command | Description |
|---|---|
npm run dev |
Start backend + frontend together, with hot reload (day-to-day dev) |
npm run build |
Build shared, then backend, then frontend |
npm run build -w shared |
Build only the shared package (needed on fresh checkout) |
npm run typecheck |
Typecheck all workspaces |
npm run db:init |
Apply schema.sql (requires DATABASE_URL exported in your shell) |
npm run db:seed |
Apply seed.sql demo data (requires DATABASE_URL exported) |
npm run db:setup |
Run db:init then db:seed (requires DATABASE_URL exported) |
npm run db:reset |
Drop + recreate schema, then setup (needed after schema changes) |
npm test |
Vitest unit + integration and Playwright e2e (needs vtt_test) |
npm run test:unit / npm run test:e2e |
Run just the Vitest or just the Playwright suite |
npm run deploy:local |
Docker: build + start (docker compose up -d --build) — see section 8 |
npm run deploy:cloud |
Docker: same, plus Caddy for a real domain (--profile edge) |
npm run deploy:down |
Docker: stop the stack (add -- -v to also wipe volumes/data) |
/healthshows"db":falseor the backend logs a connection error:DATABASE_URLis unset or wrong. Confirm.envexists at the repo root and the value matches your Postgres role/password/db name. Check the server is up withpg_isready.role "..." does not exist/password authentication failed: yourDATABASE_URLuser does not match your Postgres setup. On Linux/Windows use thepostgresuser and its password; on macOS Homebrew use your OS username with no password.Cannot find module '@vtt/shared'or type errors about it: runnpm run build -w shared(itsdist/must exist).psql: command not found: the PostgreSQL client is not on your PATH (see step 2b, especially the Homebrew PATH line).- Port already in use (4000 or 5173): another process is bound. Stop it, or
change
PORTin.env(backend) / the Vite port infrontend/vite.config.ts. - Fog looks wrong / tokens not draggable after editing shared types:
rebuild shared (
npm run build -w shared) and reload; the contract types are the source of truth for both ends. - Stale login after a
db:reset/ fresh seed: an old browser tab may still hold a token for a session that no longer exists. The app detects this and drops you back to/loginautomatically; if it doesn't, hard-refresh or use an incognito window. - Docker-specific issues: see section 8e.
docs/01-architecture.md- monorepo layout, scripts, env loading, connection modeldocs/02-database-schema.md- tables, JSONB usage, reveal/conceal SQL, seed datadocs/03-websocket-contracts.md- every event payload and the room modeldocs/04-visibility-filter.md- the server-side anti-cheat pipelinedocs/05-pixi-shroud-strategy.md- rendering, fog of war, input/draggingdocs/06-verification.md- how the system is verified and how to reproduce itdocs/07-features.md- feature backlog and status tracker (roadmap)docs/08-per-audience-visibility.md- per-player / per-audience fog design (not yet built; folded into docs/11 phase 3)docs/09-login-and-identity.md- login, sessions, and per-campaign roles design (built)docs/10-cloud-deployment.md- deployment: one Docker stack, three modes (local, self-host + tunnel, DigitalOcean); built and verified locallydocs/11-gm-toolkit.md- GM toolkit design: tab-based multi-map model, tokens, per-audience fog, session tools, builder (Phase 1 built; phases 2-5 not yet)docs/12-gm-lobby-hierarchy.md- GM Lobby hierarchy design: Games above Campaigns, reusable Map Library, standing player roster, campaign lifecycle (design only, not yet built)shared/src/contracts.ts- the authoritative payload types