- JavaScript 78%
- HTML 14.2%
- CSS 5.7%
- Shell 1.1%
- PLpgSQL 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .github/workflows | ||
| docker | ||
| docs | ||
| linux | ||
| postgres | ||
| proxmox | ||
| public | ||
| scripts | ||
| secrets | ||
| server | ||
| shared | ||
| test | ||
| website | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc.json | ||
| AGENTS.md | ||
| auth.js | ||
| CHANGELOG.md | ||
| CONTRIBUTING.md | ||
| db.js | ||
| docker-compose.yml | ||
| Dockerfile | ||
| eslint.config.js | ||
| LICENSE | ||
| migrate.js | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| server.js | ||
Open Tabletop
An authoritative-physics virtual tabletop where any game can be played, because the engine only simulates physical objects and lets people enforce the rules. One server runs a single cannon-es world and syncs piece transforms to every client over Colyseus; clients render and send intent, never physics.
Documentation
- Public website: local preview and website maintenance, with a
standalone landing page and wiki in
website/. - Getting running: Docker quick start, direct Node.js setup, and the deployment options below.
- Contributing: contributor guide for development setup, design review, testing, and pull requests.
- Understanding the code: architecture, code reference, and the file map.
- Interaction and device support: gestures, device matrix, and manual device QA.
- Project direction and releases: roadmap, changelog, and release and upgrade guidance.
- Security and assets: private vulnerability reporting, asset credits, and license.
Quick start (Docker)
The fastest way to get a table up for your group. Docker Compose brings up the app and its database together — the schema, the least-privilege DB role, and all future migrations are handled for you.
git clone https://github.com/optimuspryne/open-tabletop.git
cd open-tabletop
cp .env.example .env # set bootstrap admin username/email
mkdir -p secrets
openssl rand -base64 32 > secrets/db_owner_password.txt
openssl rand -base64 32 > secrets/app_db_password.txt
openssl rand -base64 32 > secrets/admin_password.txt
chmod 600 secrets/*.txt
docker compose up -d
Open http://localhost:2567 (or your machine's LAN IP from another device) and sign in with the
bootstrap administrator configured above. Provisioning happens only against an empty users table;
restarts never reset its password. Two named volumes keep your data:
db-data (the database) and assets (uploaded decks/boards/props/skyboxes).
Don't want to build locally? In
docker-compose.yml, swapbuild: .forimage: optimuspryne/open-tabletop:0.21.1to pull the published image instead. Upgrading later isdocker compose pull && docker compose up -d— the app auto-applies any new migrations itself.Playing beyond your LAN? Put it behind a reverse proxy with TLS — see Security & production posture.
Upgrading from 0.21.0 to 0.21.1: pull the updated image and recreate the app container. This fixes startup when an existing installation has bootstrap variables configured but its password file is missing. No new migration or configuration change is required. See 0.21.1 upgrade notes; upgrades from older versions must also follow the 0.21.0 instructions.
Prefer to run it directly with Node, bring your own Postgres, or deploy through Portainer? Those paths are below.
Run via NPM
The package declares Node.js 20.9 or newer for the runtime. Use Node.js 24 for the current npm launch commands and locked development tools, matching the production container. See the ordered Node.js setup for database grants and bootstrap setup before the first start.
# Set up Postgres and Redis first — see "Database" and "Redis" below
git clone "https://github.com/optimuspryne/open-tabletop.git"
cd open-tabletop/
npm ci --omit=dev
# Copy the .env.example file
cp .env.example .env
# Complete the Database setup below, including MIGRATE_DATABASE_URL and all three
# BOOTSTRAP_ADMIN_* settings, and set REDIS_URL before starting.
# `npm start` auto-loads `.env`.
npm start
Open http://localhost:2567 in two browser windows (or two devices on your LAN → use your machine's IP) and move pieces around together.
Database
Postgres now backs the saved-asset library (deck / board / prop / scene /
skybox metadata), user accounts, rooms + membership, and each room's
durable settings — scoreboard, notes, table size, skybox, felt color, and a
saved game snapshot. Live piece state and private hands are held in memory
during a session; they're persisted only through a snapshot — the GM's Save
Table, or an auto-save when the room empties — written into the room's
scene column and rebuilt from it on load (see "Saving & resuming games"). One-time setup:
- Database + owner role (as a superuser):
CREATE ROLE tabletop LOGIN PASSWORD '…';thenCREATE DATABASE tabletop OWNER tabletop; - Least-privilege app role (as a superuser): create
tabletop_app, a CRUD-only role (noCREATE/DROP/TRUNCATE/ALTER) that the running server connects as. Grant itSELECT/INSERT/UPDATE/DELETEon the tables andUSAGEon the sequences. - Schema. Two ways to do it:
- Let the app apply it (recommended). Point
MIGRATE_DATABASE_URLat the owner role (DDL-capable). On startup the server runs any pendingpostgres/NNN_*.sqlmigrations, tracked in aschema_migrationstable — a blank database gets the whole schema built from001onward, an existing one gets only what's new, with no manual step on upgrade. The app's ownDATABASE_URLstays the least-privilegetabletop_approle; DDL runs only through this separate owner URL, and only at boot. Works against stock Postgres or any managed instance. - Or apply it by hand (as the owner). Fresh install:
psql -U tabletop -d tabletop -f postgres/schema.sql(the flattened current schema, which also seedsschema_migrations). Upgrade: apply only pending numbered migrations in order through the last migration shipped with your target version. Version 0.21.0 requires023_account_recovery.sql; see the upgrade notes. SetAUTO_MIGRATE=false(or just leaveMIGRATE_DATABASE_URLunset) to keep the app out of the schema. (The per-migration backfills matter on a populated DB but are no-ops on an empty one, so they're dropped from the baseline.)
- Let the app apply it (recommended). Point
- Point the app at it:
cp .env.example .env, setDATABASE_URLto thetabletop_appconnection string (andMIGRATE_DATABASE_URLto the owner one for auto-migration).npm startauto-loads.env. - Provision an administrator explicitly. Set
BOOTSTRAP_ADMIN_USERNAME,BOOTSTRAP_ADMIN_EMAIL, andBOOTSTRAP_ADMIN_PASSWORD_FILEbefore the first start. Bootstrap runs only on an empty users table and never resets an existing administrator. To recover or promote an existing account, runnpm run admin:grant -- user@example.com; useadmin:revoketo remove access (the final administrator cannot be revoked).
Login credentials are separate, per-device sessions that expire after 30 days.
Set SESSION_TTL_DAYS to a whole number from 1–365 to change that lifetime.
Direct deployments normally use DATABASE_URL or DATABASE_URL_FILE. Docker Compose
uses non-secret host/name/user metadata plus DATABASE_PASSWORD_FILE; migration keys
use the same names with a MIGRATE_ prefix. There's no hardcoded credential fallback,
so missing or partial config fails loudly at startup. For a remote DB, append ?sslmode=no-verify
(encrypt only) or ?sslmode=verify-full (verified — needs the CA) to the URL, and
turn on ssl server-side.
Optional Compose settings
The bundled Compose file does not forward every .env key. TRUST_PROXY_HOPS,
SESSION_TTL_DAYS, and AUTO_MIGRATE need explicit services.app.environment
entries, for example in docker-compose.override.yml. See the
Compose configuration example.
Recreate the container after changes. Linux file secrets retain host access permissions;
the default app UID/GID is 100:101. See secret-file permissions
if files created with mode 600 are unreadable by the container.
Redis
Redis holds the shared token buckets for authentication and upload rate limits.
Set REDIS_URL (or REDIS_URL_FILE) for direct and clustered deployments. Production
startup fails if Redis is not configured, and protected requests fail closed with
503 if it becomes unavailable. RATE_LIMIT_STORE=memory is an explicit local-only
fallback; its entries are periodically expired, but its limits are not shared between
processes. If TLS terminates at a reverse proxy, set TRUST_PROXY_HOPS to the exact
number of proxies between the client and this app; leaving it at 0 ignores forwarded
addresses.
Run with Docker Compose
The repo ships a Dockerfile and docker-compose.yml that bring up the app, Postgres,
and an ephemeral Redis rate-limit store — including the two-role DB setup (owner +
least-privilege app role), applied automatically on first start.
git clone "https://github.com/optimuspryne/open-tabletop.git"
cd open-tabletop/
cp .env.example .env # set bootstrap admin username/email
mkdir -p secrets
openssl rand -base64 32 > secrets/db_owner_password.txt
openssl rand -base64 32 > secrets/app_db_password.txt
openssl rand -base64 32 > secrets/admin_password.txt
chmod 600 secrets/*.txt
docker compose up -d # builds the image, starts Postgres, then the app
Open http://localhost:2567. On the first run, Compose applies postgres/schema.sql
and creates the tabletop_app role via docker/init-app-role.sh. On later upgrades the
app auto-applies any new migrations itself at startup (via MIGRATE_DATABASE_URL),
so a docker compose pull && up is all it takes — no manual psql step. Two named
volumes persist state: db-data (the database) and assets (uploaded decks/boards/props/skyboxes).
The administrator named in .env is created from the mounted password secret only
when the users table is empty. Existing installations are left untouched. Recovery:
docker compose exec app npm run admin:grant -- user@example.com.
Upgrading an existing Compose install: initialize
secrets/db_owner_password.txtandsecrets/app_db_password.txtwith the current values of the oldDB_PASSWORDandAPP_DB_PASSWORDvariables.npm run secrets:migrateperforms that copy without printing either value and refuses to overwrite an existing secret. Existing Postgres volumes retain their role passwords; merely generating new secret values does not rotate them. After the secret-backed stack starts successfully, remove those two password entries from.env. Rotate them later only together with the corresponding PostgreSQLALTER ROLEcommands.
Single container (bring your own Postgres)
If you already run Postgres (managed or otherwise), skip Compose and run just the app image against it:
docker run --name open-tabletop-app -p 2567:2567 -v ott-assets:/data/assets \
-e DATABASE_URL=postgresql://tabletop_app:…@dbhost:5432/tabletop \
-e MIGRATE_DATABASE_URL=postgresql://tabletop:…@dbhost:5432/tabletop \
-e REDIS_URL=redis://redis-host:6379 \
optimuspryne/open-tabletop:0.21.1
# MIGRATE_DATABASE_URL (owner role) lets the app build/upgrade the schema itself;
# omit it (or set AUTO_MIGRATE=false) to apply postgres/*.sql by hand instead.
# For a remote DB, append `?sslmode=no-verify`
# (encrypt only) or `?sslmode=verify-full` (verified — needs the CA) to the URL, and
# turn on `ssl` server-side.
Open http://localhost:2567, create an account, then explicitly promote it to administrator status:
docker exec open-tabletop-app npm run admin:grant -- your@email.example
Once an admin account has been created, it can be used to promote other accounts to admin status.
Deploying via a stack in Portainer or Dockhand
Web editor
There's no custom database image — deploy against stock postgres. The app builds
and migrates its own schema on boot (via MIGRATE_DATABASE_URL). Database setup requires
the least-privilege tabletop_app role and an explicit bootstrap administrator. Since the
web editor can't mount local files, the stack below injects both the role setup and bootstrap
password as inline config files.
Before deploying, add these values to the stack's environment-variable editor. In Dockhand, mark the three password values as secrets so their saved values remain masked. These are stack variables used while Compose renders the YAML; the example explicitly passes only the values the running containers need.
| Variable | Required | Purpose |
|---|---|---|
DB_PASSWORD |
Yes | Password for the database owner/migration role. |
APP_DB_PASSWORD |
Yes | Password for the least-privilege runtime role. |
BOOTSTRAP_ADMIN_USERNAME |
Yes on first deployment | Initial admin username; 3–20 letters, numbers, _, or -. |
BOOTSTRAP_ADMIN_EMAIL |
Yes on first deployment | Initial admin sign-in email. |
BOOTSTRAP_ADMIN_PASSWORD |
Yes on first deployment | Initial admin password; 12–1024 characters. |
SESSION_TTL_DAYS |
No | Login lifetime in days; defaults to 30. |
TRUST_PROXY_HOPS |
No | Exact reverse-proxy hop count; defaults to 0. |
AUTO_MIGRATE |
No | Apply pending migrations at startup; defaults to true. |
# Open Tabletop — app + stock Postgres (Portainer/Dockhand stack)
configs:
app_role_init: # runs once on first DB init — creates the app role
content: |
CREATE ROLE tabletop_app LOGIN PASSWORD '${APP_DB_PASSWORD}';
GRANT CONNECT ON DATABASE tabletop TO tabletop_app;
GRANT USAGE ON SCHEMA public TO tabletop_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tabletop_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public
GRANT USAGE, SELECT ON SEQUENCES TO tabletop_app;
bootstrap_admin_password: # mounted read-only; read during first-user bootstrap
content: |
${BOOTSTRAP_ADMIN_PASSWORD:?set BOOTSTRAP_ADMIN_PASSWORD in the stack environment}
services:
db:
image: postgres:16-alpine
restart: unless-stopped
container_name: open-tabletop-db
environment:
POSTGRES_USER: tabletop # owner role — the app migrates the schema as this
POSTGRES_DB: tabletop
POSTGRES_PASSWORD: ${DB_PASSWORD}
configs:
- source: app_role_init
target: /docker-entrypoint-initdb.d/02-app-role.sql
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tabletop -d tabletop"]
interval: 5s
timeout: 3s
retries: 12
redis:
image: redis:7-alpine
restart: unless-stopped
command: ["redis-server", "--save", "", "--appendonly", "no"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 12
app:
image: optimuspryne/open-tabletop:0.21.1
user: appuser # matches the image's non-root runtime user
restart: unless-stopped
container_name: open-tabletop-app
depends_on:
db:
condition: service_healthy # wait until the role is in place
redis:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://tabletop_app:${APP_DB_PASSWORD}@db:5432/tabletop
# Owner (DDL) role — the app builds & migrates the schema on boot (migrate.js).
MIGRATE_DATABASE_URL: postgresql://tabletop:${DB_PASSWORD}@db:5432/tabletop
AUTO_MIGRATE: "${AUTO_MIGRATE:-true}"
REDIS_URL: redis://redis:6379
RATE_LIMIT_STORE: redis
BOOTSTRAP_ADMIN_USERNAME: "${BOOTSTRAP_ADMIN_USERNAME:?set BOOTSTRAP_ADMIN_USERNAME in the stack environment}"
BOOTSTRAP_ADMIN_EMAIL: "${BOOTSTRAP_ADMIN_EMAIL:?set BOOTSTRAP_ADMIN_EMAIL in the stack environment}"
BOOTSTRAP_ADMIN_PASSWORD_FILE: /run/secrets/bootstrap_admin_password
ASSETS_DIR: /data/assets
SESSION_TTL_DAYS: "${SESSION_TTL_DAYS:-30}"
# Set this to the exact number of reverse proxies in front of the app.
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-0}"
PORT: "2567"
ports:
- "2567:2567"
volumes:
- assets:/data/assets # uploaded decks/boards/props/skyboxes
configs:
- source: bootstrap_admin_password
target: /run/secrets/bootstrap_admin_password
volumes:
db-data:
assets:
Open http://localhost:2567 and sign in with the bootstrap administrator configured in the
stack environment. Bootstrap provisioning runs only when the users table is empty; later stack
deployments and container restarts never reset that account or its password. Once any user
exists, startup skips all bootstrap settings and password-file access. The bootstrap variables
can remain configured even if the first-boot file is no longer available. If users exist but
none is an administrator, use npm run admin:grant -- <username-or-email> for recovery.
You do not create bootstrap_admin_password on the Docker host. The top-level Compose config
renders BOOTSTRAP_ADMIN_PASSWORD into a read-only file, mounts it inside the app container at
/run/secrets/bootstrap_admin_password, and sets BOOTSTRAP_ADMIN_PASSWORD_FILE to that path.
The app reads the file, removes trailing line endings, validates the password length, and hashes
the password before storing it. The /run/secrets path is only the mount location here; this
particular value is supplied by an inline Compose config.
After changing a stack variable, use Deploy, Update, or Recreate in the stack manager so Compose regenerates the config and container. A plain container restart keeps the previously rendered config. When editing a masked Dockhand variable, retain the saved secret or enter the real value again—do not replace it with the displayed mask.
The startup message .env not found. Continuing without it. is expected in this deployment:
the stack manager supplies the environment instead. If startup reports that the bootstrap password
is too short, check the mounted value's length without displaying the secret:
docker exec open-tabletop-app node -e "const fs=require('fs'); const p=fs.readFileSync(process.env.BOOTSTRAP_ADMIN_PASSWORD_FILE,'utf8').replace(/[\\r\\n]+$/,''); console.log(p.length)"
The result must be at least 12. A result of 0 means the stack variable was empty; a result such
as 3 usually means a UI mask like *** was saved literally. Correct the variable in the stack
manager and redeploy/recreate the stack.
On first boot the db creates tabletop_app from the inline config, and the app builds the
full schema via MIGRATE_DATABASE_URL (adopting an existing schema if you're pointing at an
old volume), then provisions the bootstrap administrator. The assets volume holds uploaded
files; library metadata lives in Postgres.
The official image pins appuser to UID 100 and appgroup to GID 101. To store uploads
on NFS, provision the exported assets directory with that numeric ownership and writable directory
permissions on the NFS server (for example, chown 100:101 and chmod 2770). Then replace the
empty assets: definition at the bottom of the stack with an NFS-backed named volume:
assets:
driver: local
driver_opts:
type: nfs
o: "addr=${NFS_SERVER},rw,nfsvers=4"
device: ":${NFS_ASSETS_PATH}"
Set NFS_SERVER to the server address and NFS_ASSETS_PATH to its exported path. Ownership must
be prepared server-side, especially when the export uses root squashing; the non-root app container
cannot repair NFS ownership itself.
Older Portainer, Dockhand, or Compose without inline-config support? Drop the top-level
configs: block, both service-level configs: entries, and the three BOOTSTRAP_ADMIN_* entries.
Bring the stack up, then create the role once by hand:
docker exec -i open-tabletop-db psql -U tabletop -d tabletop <<'SQL'
CREATE ROLE tabletop_app LOGIN PASSWORD 'your-APP_DB_PASSWORD';
GRANT CONNECT ON DATABASE tabletop TO tabletop_app;
GRANT USAGE ON SCHEMA public TO tabletop_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO tabletop_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO tabletop_app;
ALTER DEFAULT PRIVILEGES FOR ROLE tabletop IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO tabletop_app;
SQL
docker restart open-tabletop-app
After the app starts, create an account and promote it explicitly:
docker exec open-tabletop-app npm run admin:grant -- your@email.example
Linux host installer
linux/open-tabletop.sh installs directly on a regular systemd
Linux host or VM: Debian 12/13, Ubuntu 22.04/24.04/26.04 LTS, package-managed Fedora, or Arch Linux.
Use a dedicated host with current distro packages. Fedora Atomic desktops and non-systemd
variants are outside this installer’s scope. Full VM installation testing on each distro is
still pending; automated command-mocked tests cover provisioning and update behavior.
It reuses the Proxmox native installer for releases, separate database roles, credentials,
upload storage, backups, and the non-root systemd service. Debian/Ubuntu use NodeSource Node.js 26,
the distro’s PostgreSQL package, and Redis. Fedora/Arch use their Node.js/npm, PostgreSQL, and
Valkey packages (Fedora package,
Arch package); Node.js 22.12 or newer
is required. Arch installation runs a full
pacman -Syu --needed --noconfirm to avoid partial upgrades. Install git first for repository
fetches (apt install git, dnf install git, or pacman -Syu git as appropriate).
From a checkout containing this launcher, install a pushed revision with:
sudo env SOURCE_REF=main BOOTSTRAP_ADMIN_EMAIL=you@example.com \
bash linux/open-tabletop.sh install
The source defaults to https://github.com/optimuspryne/open-tabletop.git at main.
Use a tag or full commit ID for SOURCE_REF to select a revision, or override SOURCE_REPO.
The installer is extracted from that same source archive. The selected revision must include
this Linux-capable companion installer; an older pushed revision will not gain Linux support
from a newer launcher alone. Admin username defaults to admin; omitting the email prompts for it.
To test these local changes before pushing, package the tracked working-tree files (including edited files), then pass that trusted archive:
git ls-files -z | tar --null -T - -cf /tmp/open-tabletop-source.tar
sudo env SOURCE_ARCHIVE=/tmp/open-tabletop-source.tar BOOTSTRAP_ADMIN_EMAIL=you@example.com \
bash linux/open-tabletop.sh install
This packaging command excludes untracked files, including new application files; add any
required new files explicitly to your archive. Archives must contain root-level package.json,
package-lock.json, server.js, and proxmox/install.sh. Only use source and archives you trust:
the companion installer executes as root. Downloading dependencies still requires internet access.
Open http://HOST-IP:2567 after installation. Credentials are stored with root-only permissions
in /root/open-tabletop-credentials.txt; configuration and password files are in
/etc/open-tabletop/. The installer allocates an open-tabletop system account without taking
UID 1000. Uploads live at /var/lib/open-tabletop/assets; releases live under
/opt/open-tabletop/releases/, with current pointing to the active release.
PostgreSQL uses local port 5432; Redis/Valkey uses 6379. Linux setup adds a localhost SCRAM rule
only for the tabletop database and its two roles, preserving other PostgreSQL access rules.
It requires unused open-tabletop user/group and tabletop/tabletop_app database roles on
first installation; it does not adopt an existing manual or Docker deployment.
Firewall rules, TLS/reverse proxy configuration, and SELinux policy remain host-managed.
Allow inbound TCP 2567 only on the intended network, or proxy it through your existing HTTPS
endpoint; keep database/cache ports private. Set proxy trust in
/etc/open-tabletop/open-tabletop.env if needed (see the deployment configuration above).
For mounted upload storage, prepare ownership for id -u open-tabletop / id -g open-tabletop
on that host and ensure the mount is available before starting the service; the installer
checks writability and does not change mount-point ownership.
sudo env SOURCE_REF=main bash linux/open-tabletop.sh update
sudo journalctl -u open-tabletop -n 100 --no-pager
Updates also accept SOURCE_ARCHIVE, preserve credentials/configuration, and create a database
dump in /var/backups/open-tabletop/ before switching releases and restarting the service.
They do not upgrade OS packages or PostgreSQL major versions. Back up uploaded files separately;
for rollback, restore both the database dump and the matching old release because migrations
may not be reversible.
To recover an interrupted install, or reinstall after removing the application while retaining data:
sudo env SOURCE_REF=main bash linux/open-tabletop.sh resume
# Equivalent: reinstall (uses the same recovery path)
sudo env SOURCE_REF=main bash linux/open-tabletop.sh reinstall
Resume/reinstall accept the same source options as install. They may install/update host packages, reuse the existing account and stored passwords, preserve the environment file and uploads, back up an existing database, and restore the service/release. They never reset existing role passwords or replace a missing database from a completed installation with an empty one. New installs save recovery state before creating accounts/roles. Older interrupted installs can be resumed if their configuration and password files exist; otherwise manual inspection is required. Custom database endpoints/credentials or asset paths require manual recovery.
Run removal from a current repository checkout on the Linux host:
sudo bash linux/open-tabletop.sh uninstall --dry-run
sudo bash linux/open-tabletop.sh uninstall
# Permanent removal of the retained application data:
sudo bash linux/open-tabletop.sh purge --dry-run
sudo bash linux/open-tabletop.sh purge
uninstall stops/disables the service and removes /opt/open-tabletop and the systemd unit.
It retains the database/roles, uploads, configuration/password files, service account, service
drop-ins, and backups so reinstall can restore the application. Removal uses the local shared
installer (or a retained /etc/open-tabletop/installer.sh), with no source download.
purge requires typing PURGE open-tabletop. It also removes the local tabletop database,
tabletop/tabletop_app roles, /var/lib/open-tabletop, /etc/open-tabletop, service drop-ins,
the app user/group, and /root/open-tabletop-credentials.txt. It makes a fresh database backup
before dropping an existing database and retains /var/backups/open-tabletop. Back up uploaded
files separately before purging. Backup or database failures stop removal; a failure after the
service has stopped may leave it stopped. Role dependencies outside this database cause an error,
not cascading deletion. Inspect the error before retrying a partial purge.
Purge refuses mounted storage (including nested bind mounts), redirected installation roots,
shared filesystems, and customized database/assets settings. Detach external storage and handle
its contents separately; the script never unmounts it. Node.js, PostgreSQL, Redis/Valkey, package
repositories, unrelated HBA rules, and host firewall/proxy settings remain installed. Only the
exact installer-added PostgreSQL authentication rule is removed. --dry-run makes no changes.
For a dedicated Proxmox deployment, removing the container remains a separate host operation;
these Linux commands do not delete a container or its host-mounted storage.
Manual acceptance remains: fresh install and reboot on each distro, administrator login, asset upload, two-client room connection, then update, uninstall/reinstall, interrupted-install resume, and purge with backups and unrelated services preserved. The installer restarts the application; refresh clients after an application update.
Local Proxmox LXC installer
The scripts in proxmox/ provide a local, Community Scripts-style deployment while
the app is not listed in the Proxmox VE Community Scripts catalog. On a Proxmox VE 9 or newer
host, download only open-tabletop.sh. It fetches the app source from
https://github.com/optimuspryne/open-tabletop.git at main by default and extracts its companion
proxmox/install.sh from that same source revision. It creates an unprivileged Debian 13 LXC with
nesting enabled so Debian's Redis systemd service can create its user namespace;
the companion installs Node.js 26, PostgreSQL 16, Redis, and the app directly inside it and enables
a non-root systemd service. Docker is not used. The Proxmox host needs git for this fetch.
Nesting does not make the container privileged, but it relaxes isolation by exposing some host
/proc and /sys information to the guest.
After this code is pushed, sign in to the Proxmox host and download the host script from a specific
commit. Set SOURCE_REF to that commit too, so the fetched app and companion match the host script.
Find the commit SHA with git rev-parse HEAD on your workstation after committing. Replace REV,
email, storage, and CTID with your values. For NFS-backed uploads, mount the export
on the Proxmox host first and set ASSETS_HOST_PATH to a directory on it; omit that variable if
uploads should live inside the container.
ssh root@YOUR-PVE-HOST
REV=PASTE_FULL_PUSHED_COMMIT_SHA
curl -fsSLo /root/open-tabletop.sh \
"https://raw.githubusercontent.com/optimuspryne/open-tabletop/$REV/proxmox/open-tabletop.sh"
SOURCE_REF="$REV" CTID=123 ROOTFS_STORAGE=local-lvm BOOTSTRAP_ADMIN_EMAIL=you@example.com \
ASSETS_HOST_PATH=/mnt/open-tabletop-assets \
bash /root/open-tabletop.sh install
Inspect the downloaded script before executing it as root. The app and container installer come from the selected Git revision, not from the downloaded script's URL; pinning both to the same commit keeps them in sync. Only pushed commits can be fetched this way.
The script offers the next free CTID when CTID is unset and prompts for storage and the admin
email when those values are unset. Other optional settings are TEMPLATE_STORAGE (default local),
BRIDGE (vmbr0), IP (dhcp, or an IPv4 CIDR with GATEWAY), CORES (2), RAM_MB (2048),
DISK_GB (12), CT_HOSTNAME (open-tabletop), and BOOTSTRAP_ADMIN_USERNAME (admin).
For a new LXC, the launcher waits for an IPv4 address and default route before package setup. If
they do not appear after about 30 seconds, it reboots the LXC once and waits again. A second
failure stops the install without deleting the container so you can inspect its DHCP and bridge
configuration. The source and installer have already been copied into the LXC, so after fixing its
network you can resume without recreating it:
SOURCE_ID=$(pct exec 123 -- sha256sum /root/open-tabletop-source.tar | cut -c1-40)
pct exec 123 -- env SOURCE_ID="$SOURCE_ID" BOOTSTRAP_ADMIN_USERNAME=admin \
BOOTSTRAP_ADMIN_EMAIL=you@example.com bash /root/open-tabletop-install.sh install
The same network check also applies when IP and GATEWAY specify a static address.
The installer creates database passwords and a strong initial admin password; read them inside the
container with pct exec 123 -- cat /root/open-tabletop-credentials.txt. Keep that root-only file
private. The service listens on port 2567; use a reverse proxy with TLS for internet access and
set TRUST_PROXY_HOPS in /etc/open-tabletop/open-tabletop.env to the actual proxy hop count.
The container installer retries APT index updates and fails on transient fetch errors instead of
using stale package lists. If a first install stops during package setup or while starting PostgreSQL
or Redis, it can be retried after fixing the underlying problem; its NodeSource key import allows an
existing key. This does not make arbitrary later installation failures automatically recoverable.
Set SOURCE_REF to a branch, tag, or commit to deploy something other than main; SOURCE_REPO
can point at another Git remote. To test committed app code that you have not pushed, copy
the host script and a source archive from your workstation instead:
git archive --format=tar --output=open-tabletop-source.tar HEAD
scp proxmox/open-tabletop.sh open-tabletop-source.tar root@YOUR-PVE-HOST:/root/
SOURCE_ARCHIVE=/root/open-tabletop-source.tar CTID=123 ROOTFS_STORAGE=local-lvm \
BOOTSTRAP_ADMIN_EMAIL=you@example.com bash /root/open-tabletop.sh install
SOURCE_ARCHIVE takes precedence over the Git fetch and must include proxmox/install.sh.
Uncommitted files are not included in a Git archive. To test an uncommitted companion script,
copy it separately and set INSTALLER_PATH=/root/install.sh when running the host script.
To put uploads on NFS, mount the export on the Proxmox host first and set
ASSETS_HOST_PATH=/path/to/mounted/assets at initial installation. The script bind-mounts that
directory into the LXC at /var/lib/open-tabletop/assets and checks that the app can write to it.
It verifies the path is currently on an NFS filesystem so a missing host mount cannot quietly put
uploads on the Proxmox host's local disk. Ensure that NFS mount is available before the LXC starts
after a host reboot.
The LXC app user is UID/GID 1000:1000. With Proxmox's default unprivileged mapping, that is
101000:101000 on the host and NFS server, so prepare the export for those numeric IDs.
This differs from the Docker image's 100:101. A custom LXC ID map changes the host IDs. Proxmox
container backups do not include bind-mounted NFS data; back up that export separately.
To deploy a newer pushed commit, run bash /root/open-tabletop.sh update 123 on the Proxmox host
(or pass a new SOURCE_ARCHIVE). The update creates a PostgreSQL dump under
/var/backups/open-tabletop/, installs dependencies into a new release directory, switches the
service to it, and checks HTTP readiness. Older release directories remain available. Database
migrations may not be reversible, so restore the database dump along with an older release if an
upgrade must be rolled back. For logs, run
pct exec 123 -- journalctl -u open-tabletop -n 100 --no-pager.
Testing
npm test # fast unit/harness suite; no services required
npm run check # ESLint, formatting, CSS checks, and fast tests
npm run test:input # browser input/gesture tests
npm run test:components # browser component and notecard tests
npm run test:devices # responsive/device layout checks
npm run test:integration # PostgreSQL integration suite
npm run audit # production dependency vulnerability audit
By default, test:integration starts a randomly named PostgreSQL 16 container on a
random local port, applies the production schema and least-privilege app grants,
runs the real database tests, then removes the container and its storage. This
mode requires Docker, but not Docker Compose. Alternatively, provide both
TEST_DATABASE_OWNER_URL and TEST_DATABASE_URL for a dedicated test database;
the runner then uses that database without starting Docker. The owner URL must
allow schema setup and the app URL must use the least-privilege runtime role.
Both URLs must name a database ending in _test. Use disposable test data:
the suite prepares the schema and mutates database contents.
Browser checks require Chrome/Chromium; use CHROME_BIN to select the executable.
See CONTRIBUTING.md for checks required by change type and
Device QA for manual acceptance. Automated checks do not
establish real-device gesture feel, GPU performance, or multiplayer correctness.
CI runs quality checks, input/component browser suites, a production dependency audit, and integration tests against its own PostgreSQL 16 service.
What's in the box
- Authoritative shared table. The server simulates every movable object with cannon-es and synchronizes transforms through Colyseus. Players can grab, throw, flip, rotate, recolor, keep upright, snap to a square/hex grid, and batch-operate a local multi-selection without exposing physics authority to the browser.
- Dice and personal trays. Numbered d4, d6, d8, d10, d12, and d20 share their mesh/collider geometry. Body and number colors are independent. Each seat can open a private-positioned tray, stock dice into it, roll or re-rack only that seat's dice, and clear it. Re-racking spaces dice by collider size near the center and settles them without an overlap-induced bounce.
- Props and dispensers. Built-ins include primitive solids, checkers, Go
stones, coins, poker chips, a generic token, and a complete modeled chess set.
Finite chip/coin stacks dispense matching pieces and shrink; Go bowls dispense
unlimited team-colored stones. Compatible pieces dropped back onto a dispenser
are absorbed. Custom
.glbprops support scale, orientation, collider choice, standing behavior, and material tinting. - Cards, decks, hands, and tiles. Standard decks support optional jokers, private hands, face-down cards, shuffle/split, draw-to-hand, deal-and-drag, draw-to-inspect, hold-to-show, and whole-hand drops. The same hidden-information system powers dominoes, letter tiles, mahjong, custom card geometry, and modeled deck skins such as the recolorable pouch. See the detailed section below.
- Boards, grids, and measurement. Built-in modeled Chess/Checkers and Go boards plus the procedural Wordy board can calibrate the room grid. GMs control cell size, offsets, snap anchor, square/hex style, visibility, color, height, measurement units, and rounding. Players can place durable rulers, lines, circles, and cones; a live preview is shown while positioning them.
- Seven one-click games. Chess, Checkers, Go, Dominoes, Wordy McWordface, Mahjong, and Poker Night set up their board, pieces, deck, starting hands, bowls, or chip stacks as appropriate.
- Drawable notecards. Create cards with freehand drawings, text, paper styles, and portrait/landscape layouts. Cards can be flipped and organized into stacks; reusable notecard templates are saved separately from room inventories. See notecard behavior.
- GM concealment and map fog. GMs can hide/reveal objects and spawn pieces hidden; hidden objects are omitted from player state and do not collide. Board fog supports manual reveal/cover brushes, adjustable thickness, and session undo. Configurable piece auras reveal fog as visible pieces move. Exploration persists through saves; fog and object hiding are separate controls and do not enforce game rules. See visibility and fog.
- Room presentation and collaboration. Resizable/recolorable felt, built-in or custom equirectangular/cubemap skyboxes, seated name/avatar markers, turn tracking, attention pings, a shared timer, scoreboard and GM notes, public chat, private notebooks, and a tilt-up single-drawer whiteboard.
- Per-player controls. Return to seat, Lean In, dice-tray controls, show/drop hand, role-aware help, and separate local SFX/music volume, mute, shuffle, and track selection. Held pieces show the holder's name; touch has long-press menus for the same common actions available to mouse users.
- Accounts, rooms, and recovery. Passwordless quick-join players and approved password hosts use persistent device sessions. Per-room roles, optional join approval with live admit/decline notification, reconnect support, durable room settings, explicit checkpoints, and auto-saved game snapshots keep play resumable.
- Admin-curated library. Admins create, test, publish, rename, and delete
private/public decks, boards, props, scenes, and skyboxes in the live workshop.
Asset metadata lives in Postgres, uploaded files live under
ASSETS_DIR, and orphan cleanup moves unreferenced files to a recoverable trash directory.
Files
server.js composition root: Colyseus rooms, simulation, remaining handlers, HTTP/security
db.js production Postgres pool composed from server/database.js
auth.js password hashing (scrypt) and device-token helpers
migrate.js owner-role startup migration runner
package.json runtime dependencies and npm scripts
.env.example direct-run and Compose configuration examples
server/
physics.js Cannon world setup and collider construction
database.js injected library, user, room, membership, and state queries
*-queries.js focused library/user/room read-query modules
*-config.js database, Redis, and session configuration
permissions.js room-role ranking and authorization helpers
message-validation.js socket payload normalizers and bounds
rate-limit.js Redis/memory token buckets and HTTP middleware
bootstrap-admin.js first-boot administrator provisioning
assets/ image and self-contained GLB upload validation
game/
handlers/ movement, pieces, cards, library, rooms, overlays, and members
trays.js personal dice-tray physics and lifecycle operations
scene-persistence.js portable scene/game serialization and restoration
safe-message.js Colyseus message and lifecycle error boundaries
props-codec.js canonical synced-piece props encoding
http/
routes/ auth, rooms/profile/host, admin, upload, and texture-derivative routers
async-route.js async Express error boundary
auth-context.js Bearer-user and administrator guards
shared/pieces.js shared piece physics/render data, registries, geometry, grids, and trays
postgres/ numbered migrations, flattened schema, and app-role grants
scripts/ admin roles, icon generation, secret migration, DB integration runner
test/ unit/harness tests plus PostgreSQL integration tests
docs/ architecture, code reference, credits, release, and design notes
docker/ first-start least-privilege Postgres role setup
Dockerfile production Node 24 image
docker-compose.yml app + Postgres + Redis with Docker secret files
proxmox/ host LXC launcher and shared native installer
linux/ regular Linux host launcher
public/
index.html/landing.js lobby, authentication, room list, and host requests
table.html/client.js table shell and runtime composition root
editor.html compatibility redirect to table.html?workshop=1
admin.html/admin.js site administration UI
credits.js central music, sound, art, model, and library attribution
editor/ library workshop and board/collider authoring
rendering/ scene/renderer, mesh/texture builders, collider surfaces, perf
ui/ shared icons, rows, surface mechanics, early UI preferences
table/ table feature controllers, input/gesture helpers, audio playback
styles.css shared design tokens, components, and page layouts
vendor/ self-hosted Three.js and Colyseus browser libraries
static_assets/ bundled mahjong, sky, textures, models, music, and sounds
Bundled files live in public/static_assets/{mahjong,sky,textures,models,music,sounds}/.
Their public URLs stay /mahjong/..., /sky/..., /textures/..., /models/..., /music/...,
and /sounds/..., so existing saved games, scenes, and library references remain valid.
To rename or relocate the bundle, move those six folders together and change only
STATIC_ASSETS_DIR in server/static-assets.js (a project-relative or absolute filesystem path).
Restart the server afterward. Production serving, browser test fixtures, and assets:colliders
all use this setting; browser code and saved URLs need no edits. Keep deployments supplied with
the configured directory if it lives outside the application tree. Uploaded assets continue to
use ASSETS_DIR and /assets/... independently.
The main game-client chain is shared ← core ← graphics ← client; client
composes the focused table/ controllers and also imports controls and
audio ← credits. table.html loads
client.js and editor/editor-panel.js; editor.html redirects to its ?workshop=1 mode.
The landing and admin pages
are standalone (landing.js / admin.js, plain fetch to the HTTP API).
Nothing is bundled or transpiled — Three.js (via an import map) and Colyseus are self-hosted under public/vendor/, so there are no third-party CDN fetches at runtime. That's also what makes the enforced script-src 'self' Content-Security-Policy possible.
Tuning knobs (edit and reload)
TRAYinshared/pieces.js— personal dice-tray footprint, visual wall, collision-wall scale, ceiling thickness, track placement, die spawn/recovery, and Scoop spacing. The defaultcollisionWallScale: 1.5raises the invisible collision walls and ceiling without raising the visible walls.trayParts()builds the visible mesh;trayCollisionParts()builds the server collider.SIMinserver.js— simulation feel: gravity, damping, card-stack stability (SIM.cards.colliderThickis the main dial), solver iterations, timestep, throw/roll behavior, collision sounds, spawn/bounds behavior, self-righting, and the live-piece cap. Piece dimensions and collider construction themselves live inshared/pieces.jsandserver/physics.js.CONFIGinpublic/rendering/core.js— client feel: grab/scroll height, model normalization size, render delay, input thresholds, inspect zoom, drop-marker and measurement-overlay appearance, spawn/upload ranges, texture resolutions, and shuffle animation.LIGHTINGinpublic/rendering/core.js— hemisphere fill, sun, environment-map strength (three numbers).- Server limits in
server.js—TABLE_LIMIT(resizable-table bounds),SCENE_MAX_BYTES(snapshot-size guard), andGRID_LIFT_MAX(maximum grid height). Placed-template caps areOVERLAY_LIMITS.maxRoom/maxPerPlayerinshared/overlays.js; the cleanup age guard isMIN_AGE_MSinserver/asset-cleanup.js. - Whiteboard —
RESOLUTIONandBOARDinpublic/table/whiteboard.jscontrol canvas resolution and physical size/placement. Replay and protocol limits come from sharedWHITEBOARD_LIMITSinshared/overlays.js, used by the client and server. - Input and cameras —
LEAN_AMOUNTinpublic/client.jscontrols the Lean In offset;VIEWinpublic/table/presence.jscontrols the normal seat camera.HAND_HOVERinpublic/table/hand.jscontrols a dragged hand card's preview height; the tray camera and transition live inpublic/table/trays.js. Inpublic/table/controls.js,LONG_PRESS_MS/LONG_PRESS_SLOPcontrol touch long-press timing and movement tolerance; keep the slop aligned withCONFIG.input.dragPx. - Rendering —
SHADOW_MARGINinpublic/rendering/core.jspads the directional-light shadow camera around the live table.
Add a piece variant or kind
The small path is a new variant of an existing kind. Add data to the relevant
registry in shared/pieces.js (PROPS, BOARDS, DISPENSERS, deck/tile data,
and so on), add any required mesh or painter support in public/rendering/graphics.js, and
expose it through the built-in or library UI. Existing spawning, synchronization,
movement, and scene persistence can then reuse that kind's established behavior.
A genuinely new synced kind touches more seams:
- Add its mass/shape descriptor to
KINDSinshared/pieces.jsand its mesh plus interaction verbs to the clientKINDregistry inpublic/rendering/graphics.js. - Extend
spawnPayloadinserver/message-validation.jswith an exact, bounded props schema; unknown kinds are rejected rather than passed through. - Add collider construction in
server/physics.jswhen the generic boxed-shape fallback is not sufficient. - Add a spawn/library UI path and any new socket handlers, including payload validation and the appropriate role checks.
- Update client lifecycle behavior where needed: props/count-driven mesh rebuilds, inspection, touch menus, selection actions, labels, and special interactions.
- Extend scene serialization/restoration if the kind carries hidden, ordered, or otherwise specialized server-only state. Plain public props already round-trip.
- Add tests for spawn validation, mesh/collider geometry, authorization and custom handlers, plus scene round-tripping.
Generic movement and transform synchronization are reusable, but kind-specific behavior is intentionally explicit at the trust, physics, UI, and persistence boundaries.
Decks, hidden hands & the privacy invariant
The public deck piece contains its back, geometry/skin, and card count; its ordered
fronts remain in the server-only deckCards map. The visible stack and collider
shrink with that public count, but clients cannot inspect the remaining order.
- Deck actions: left-click draws the top card directly to your private hand; left-drag deals it face-down and adopts it into the drag; right-drag moves the deck; right-click opens its action menu; double-click draws privately into inspect. The menu exposes draw, shuffle, split, move, peek and permission-gated browsing actions. A loose card released onto a deck is absorbed into that deck.
- Table cards: a face-down card publishes only its back and geometry. Its front
stays in
cardDatauntil the card is flipped or taken. Left-click takes a card to hand and right-click flips it; group actions can flip or take a selection. - Private hands: only the owner receives the
handmessage containing card fronts. Other players see the public count and chosen hand-back image. Cards can be played face-up or face-down individually, the whole hand can be dropped around a chosen point, and hold-to-show sends selected cards only to the chosen audience while publishing merely aSHOWING nbadge. - Tiles and custom geometry: dominoes, letter tiles, mahjong, and custom-shaped
image cards are still the
cardkind. Public geometry follows a card through deck → hand → table while its face remains private, so thickness, aspect, square/rounded/hex shape, and snap behavior survive every transition. A deck's optional 3D skin remains with the deck and round-trips through scene snapshots. - Persistence and reconnects: a saved game stores hands and turns by stable account ID. Returning players reclaim them automatically; absent owners appear as unclaimed hands that a GM can reassign. A reconnect explicitly requests the private hand again because it is not part of synchronized room state.
The invariant is unchanged: if it is synchronized, treat it as public; secrets remain server-only and are sent directly only to their intended player or audience. That includes deck order, face-down fronts, hands, inspect draws, active show audiences, and pending hands restored from a snapshot.
Draw-to-inspect
Double-click a deck to draw its top card privately into an enlarged inspect view (the front is sent to you alone, like a hand of one; the deck count drops for everyone). Then place it: F field face-up · D field face-down · H hand · R / click-away returns it to the top of the deck.
Saving & resuming games
There are two distinct kinds of "save," on purpose:
- A scene is a portable template — table size + pieces + deck order + face-down faces, and nothing about players. It's admin-curated in the editor library and loads onto any table (see "The asset library").
- A game snapshot is a scene plus the live private layer — each player's
hand and whose turn it is — saved per room so a game in progress can
be put down and picked back up. A GM writes one with GM Controls > Save Table,
and the server also auto-saves as the last player leaves and the room is
about to dispose, so progress survives an empty room even if nobody clicked save.
The snapshot lives in that room's
scenecolumn and is rebuilt on the next load.
Hands and the turn are keyed to accounts, not to the ephemeral session id, so they rebind cleanly on return:
- A returning player automatically reclaims their own hand (and the turn, if it was theirs) on rejoin — matched by account, not by seat.
- A hand whose owner hasn't come back is held as unclaimed. The GM sees an Unclaimed hands list at the top of the Members panel and can hand each one to any present player from a "Give to…" picker.
- A turn left with an absent player shows in the turn panel as "⏳ Waiting on {name}" until that player rejoins or a GM presses Next Turn.
The privacy invariant holds across the whole cycle: deck order, face-down faces, and hands are stored in the snapshot but never enter broadcast state — on load they're rebuilt into server-only memory and each hand is sent privately to its owner, exactly as in a live session.
Custom decks & card art
Site administrators open GM Controls > Add to Library at a game table, or
Menu > Room > Add to Library on a narrow screen. Choose Image Based Decks
for uploaded fronts and an optional back image, or Text Based Decks for a named
deck with front text entered one per line, comma-separated, as JSON, or from a
.csv/.txt file. Double-Sided Tiles has its own tab.
Save stores the asset; Save + Spawn also places it on the current table. Image decks can enable Fit to image to use the first front image's aspect ratio, with thickness and rounded/square/hex shape controls. Find saved decks and tiles in Library > Decks & Tiles; Filters > Custom hides built-in entries. For the standard playing-card deck, spawn its built-in Library entry.
The asset library (admin-curated)
The combined Library contains built-in and saved custom assets. Its tabs are Decks & Tiles, Objects & Dispensers, Boards, Mats & Notecards, Games & Scenes, Skyboxes, and Collections. Search finds assets by name; Filters exposes All / Custom / Built-In and By Collection.
The library is shared across rooms. Helpers can use permitted decks, objects, mats, and notecards; boards, game setups, scenes, and skyboxes require GM access. Creation, editing, cloning, publishing, renaming, deleting, and portable asset import/export require a site administrator. New custom assets are private. Use the asset's overflow menu to Publish it for users with the required room role. Publishing a collection does not publish its assets.
Admin creation tools are available at regular tables. Admin > Library Editor
opens an optional separate workshop at /table.html?workshop=1; /editor.html
redirects there. The workshop uses the same table engine and library controls.
GM Controls > Save Scene saves a named reusable setup, with an optional Include current lighting setting. Find it under Library > Games & Scenes > Scenes. A GM can use Load on a published scene, or use GM Controls > Scenes as a shortcut to that section. Loading clears the current table. Administrators can also load private scenes. Save Table checkpoints a persistent game room with player hands and turn ownership; Save Scene does not. The workshop is not a persistent game room and cannot use Save Table.
See the assets and scenes guide for the form-by-form steps, narrow-screen menu paths, collections, and package import/export.
Metadata lives in Postgres (custom_decks / custom_boards /
custom_objects), keyed by a row id, with owner_id (the creating admin)
and is_public; uploaded image and model files stay on disk under
ASSETS_DIR and are served from /assets. Uploaded images are POSTed to
/upload (not sent over the socket); .glb models to /upload-model.
Files get random names, so unrevealed fronts stay hidden.
saved-assets/ (ASSETS_DIR, default ./saved-assets — image/model FILES only)
uploads/ decks/ boards/ props/
.texture-cache/v1/ generated card/tile WebP derivatives (reproducible)
/assets/<kind>/<file> is served statically. Card faces are stored as
references (a /assets/… URL or a procedural string like rank:A:♠:#000),
never image bytes — so a full backup of image decks means dumping the DB and
copying saved-assets/. Bundled models live separately under public/static_assets/models/
(trusted, shipped with the app — no upload path).
For rendering, local uploaded card/tile faces are served through a persistent 768-pixel WebP
derivative. The original remains unchanged, while the generated .texture-cache/v1 copy reduces
cold-room transfer and is cached immutably by the browser. The derivative cache can be regenerated
from the originals and does not need to be included in backups.
Custom .glb models
As a site administrator, open GM Controls > Add to Library and choose
3D Objects or 3D Game Boards. Upload a .glb, name it, configure its
size and collision settings, then choose Save or Save + Spawn.
Objects provide scale, orientation, collider, default-material, and material-recolor
controls. Boards provide Longest side, Board outline, and Custom 3D collider
options. A selected custom collider must be created before saving. Board outlines
change collision only; they do not redraw the artwork.
Saved objects appear in Library > Objects & Dispensers > Objects; boards appear in Library > Boards, Mats & Notecards > Boards. Use Filters > Custom to narrow the results. Edit reopens supported saved assets in the builder; Clone in the asset overflow menu creates a separate named copy. Built-in model pieces retain their shared definitions and authored materials. Bundled asset licenses vary; see asset credits.
Sound effects
Built-in sound effects live in public/static_assets/sounds/. *-drop clips fire on the real
physics impact — the moment a piece lands on the table, not when you let go —
and everyone at the table hears the landing; *-pickup clips are local (only you
hear yourself grab something). Per-player effect/music volume and mute live under
the 🔊 Sound tool.
Each action maps to a list of clips in the SOUNDS map in public/table/audio.js,
and one is picked at random each time it plays — drop several files in and name them
however you like (e.g. die-roll-1.ogg, die-roll-2.ogg); only the array decides
what's used. A bare string works too, and missing files are skipped silently, so the
app runs fine before you add any audio.
| action | plays when | who hears it |
|---|---|---|
| die-roll | one die rolls | everyone |
| dice-roll | multiple dice roll | everyone |
| card-flip | a card is flipped | everyone |
| card-pickup | grab a card, deal-drag off a deck, or take one to hand | you |
| card-drop | a dealt/played card lands, or one you dropped hits the table | everyone |
| tile-pickup | grab/draw a tile (domino, word tile, mahjong) | you |
| tile-drop | a tile lands (dealt, played, or dropped) | everyone |
| shuffle | a deck is shuffled | everyone |
| die-pickup | you grab a die | you |
| die-drop | a die you dropped hits the table | everyone |
| deck-pickup | you grab a deck | you |
| deck-drop | a deck you dropped hits the table | everyone |
| tiledeck-pickup | you grab a tile deck (its wooden box) | you |
| tiledeck-drop | a tile deck (box) you dropped hits the table | everyone |
| object-pickup | you grab a prop or board | you |
| object-drop | a prop or board you dropped hits the table | everyone |
| hand-drop | you dump your whole hand to the table | everyone |
Background music is a separate HTML5 player (playlist + credits from credits.js).
Bundled SFX should be CC0 (freesound.org's CC0 filter, kenney.nl) so they carry no
attribution burden; the music (Kevin MacLeod, CC BY 4.0) is credited in-app in the
🔊 Sound panel.
Accounts, rooms & roles
Quick join creates a passwordless player with a username/email identity and a device session in the browser. A password can be added independently through Account security. Anyone with an account can request to join a room by code; only an approved host (or site administrator) can create one. Password ownership alone is not host approval.
Rooms have a join code, an owner, and an optional require-approval gate. With approval on, a joiner waits as pending until a GM admits them (the landing page polls and auto-forwards on approval); with it off, they're admitted immediately. Owners rename, toggle approval, and close their rooms from the lobby; closing disposes the live room.
Roles rank owner → GM → helper → player, are per-room membership, and are stamped onto the connection at join and enforced server-side:
- player — move/throw pieces, play their own hand.
- helper (+) — spawn built-in props/dice and public library decks/props.
- GM (+) — reshape/reset the table, spawn public boards, and manage members (admit / kick / promote — and reassign an unclaimed hand from a resumed game) from the in-table Members panel.
- owner — the room's creator; a GM other GMs can't manage.
A site admin is a global flag, not a room role: admins join any room as a GM, can spawn private library assets anywhere, and curate the library.
Host approval: creating rooms requires approved host access. Signing up with a password lands an account in a pending state (they can still play, just not host); a passwordless player can request host access, complete the Account security password form, then continue the approval request. An admin approves / rejects / revokes from the console — revoking keeps the password, so they can re-request. Admins host regardless and stay out of the queue.
The admin console (/admin.html, admins only) lists every room (including
soft-deleted, with restore / purge) and every user (grant/revoke admin,
approve/reject/revoke host, delete). A pending-host count shows on the Users
header and on the lobby's Admin link.
Security & production posture
Implemented protections include server-side account and room permissions,
admin-gated library curation, validated uploads (glTF magic + external-URI
stripping, image magic bytes), Redis-backed per-IP rate limits shared across app
replicas for uploads and auth, a per-user
cross-room live kick, a socket push for the "you're admitted" signal (with a
slow poll fallback), account avatar uploads, and an enforced Content-Security-
Policy — script-src 'self', no unsafe-*, with Three and Colyseus self-hosted
under public/vendor/ (no CDN). The first administrator is explicitly provisioned
from a password file before the public listener opens; ordinary signup never grants admin.
Remaining optional hardening (post-parse model complexity limits and per-user storage
caps) is noted in the architecture documentation.
For internet-facing deployments, use a reverse proxy with TLS, configure
TRUST_PROXY_HOPS for your actual proxy chain, and keep database/cache ports
private. Report suspected vulnerabilities privately using SECURITY.md.
Notes
defineTypes()(build-step-free schema) is deprecated but works in 0.17.- No client build step: Three.js (via import map) and the Colyseus SDK are both
self-hosted under
public/vendor/— no third-party CDN fetches at runtime. - Releases follow SemVer; see CHANGELOG.md for changes and RELEASING.md for how a release is cut.