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.
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 -dOpen 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.12.2to 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.
Prefer to run it directly with Node, bring your own Postgres, or deploy through Portainer? Those paths are below.
# Set up Postgres and Redis first — see "Database" and "Redis" below
git clone "https://github.com/optimuspryne/open-tabletop.git"
cd open-tabletop/
npm install
# Copy the .env.example file
cp .env.example .env
# Then set `DATABASE_URL` and `REDIS_URL` in .env.
# `npm start` auto-loads `.env`.
npm startOpen http://localhost:2567 in two browser windows (or two devices on your LAN → use your machine's IP) and move pieces around together.
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 State, 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 the numbered migrations in order (001_custom_assets.sql→ … →011_user_sessions.sql). 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.
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.
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 appOpen 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.
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.12.2
# 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.exampleOnce an admin account has been created, it can be used to promote other accounts to admin status.
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; administrator provisioning is described after
the stack. Since the web editor can't
mount local files, the stack below injects that role setup as an inline config. Set
DB_PASSWORD and APP_DB_PASSWORD in the stack's environment.
# Open Tabletop — app + stock Postgres (Portainer 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 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;
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.12.2
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:
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
ASSETS_DIR: /data/assets
REDIS_URL: redis://redis:6379
# PORT: 2567
ports:
- "2567:2567"
volumes:
- ./assets:/data/assets # uploaded decks/boards/props/skyboxesOpen http://localhost:2567, create an account, then explicitly promote it to administrator status:
docker exec open-tabletop-app npm run admin:grant -- your@email.exampleOnce an admin account has been created, it can be used to promote other accounts to admin status.
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). The assets volume holds uploaded files; library metadata lives in
Postgres.
Older Portainer/Compose without inline-config support? Drop the configs: block and
the db.configs: entry, 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 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-appnpm test # fast unit/harness suite; no services required
npm run test:integration # disposable PostgreSQL 16 integration suite
npm run check # lint, formatting, and fast teststest:integration starts a randomly named PostgreSQL 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. Docker is required;
Docker Compose is not. The runner refuses any database whose name does not end in
_test.
CI runs the same integration tests against its own PostgreSQL 16 service.
- 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.
- 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 deck skins such as the bentwood box. 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.
- 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.
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
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, and upload 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/ migrations 001–011, 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 22 image
docker-compose.yml app + Postgres + Redis with Docker secret files
public/
index.html/landing.js lobby, authentication, room list, and host requests
table.html/client.js table shell and runtime networking/interaction/render loop
editor.html compatibility redirect to table.html?workshop=1
editor-panel.js library workshop: create, curate, preview, and spawn assets
admin.html/admin.js site administration UI
core.js Three.js scene/camera/renderer plus CONFIG and LIGHTING
graphics.js textures, meshes, model loading, and the KIND registry
controls.js mouse/touch profiles converted to device-neutral intents
audio.js/credits.js local SFX/music playback and attribution manifests
icons.js/equalize.js shared icon behavior and early UI preference restoration
styles.css shared design tokens, components, and page layouts
vendor/ self-hosted Three.js and Colyseus browser libraries
models/, sounds/ bundled models and sound effects
The main game-client chain is shared ← core ← graphics ← client, with
client also importing controls and audio ← credits. table.html loads
client.js and 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.
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/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/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),GRID_LIFT_MAX(maximum grid height),OVERLAY_MAX/OVERLAY_MAX_PER_PLAYER(placed-template caps), andORPHAN_MIN_AGE_MS(cleanup age guard). - Whiteboard —
WHITEBOARD_RESandWBinpublic/client.jscontrol canvas resolution and physical size/placement.WHITEBOARD_MAX_STROKESexists in bothserver.jsandpublic/client.js; keep the two values equal so server history and the client's replay mirror have the same cap. - Input and cameras in
public/client.js—LEAN_AMOUNTcontrols the Lean In offset,HAND_HOVERthe whole-hand drop preview height,VIEWthe normal seat camera, andTRAY_CAMthe dice-tray camera and transition. Inpublic/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/core.jspads the directional-light shadow camera around the live table.
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/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/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 shuffles; double-click draws privately into inspect. The touch menu exposes draw, shuffle, split, move, inspect, and save 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.
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.
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 Save Table State,
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.
Card faces are texture references: rank:A:♠:#000 (procedural), text:…
(procedural text card), tback:… (colored back), a data:/URL image, or a
procedural tile face (domino:a:b, letter:A:1, or a bundled mahjong image). The
+ Deck dialog builds a deck from text (one per line / comma / JSON) or
uploaded images, with a "Save this deck as…" field to persist it on creation.
There's also a "Spawn Built-in Deck" for a standard 52.
An image deck can turn on Fit to image to size its cards to the uploaded art's
aspect (no crop/stretch), and then set the card thickness and shape
(rounded / square / hexagon). All of this is a single props.geom on the deck —
the same variable-geometry system tiles use — read by both the mesh and the
collider (see ARCHITECTURE.md). A deck can also carry a
deckModel skin (a bag/box .glb) via the DECK_MODELS registry.
The saved library (decks, boards, props, scenes, skyboxes) is global (every
room sees it) and admin-curated. Site admins create assets through an
Add to Library builder in a dedicated editor (/editor.html) — an
admin-only room that reuses the table engine, so an asset can be spawned and
tested live as it's built — and manage them (publish/unpublish, rename, delete)
from the shared View Library. Every asset carries a public/private flag:
- private (a new asset's default) — only admins can spawn it;
- public — admins still own curation, but GMs and helpers can now spawn it into their games too.
Both the editor and the game table share the same asset UI — a View Library (browse + spawn/apply saved assets) and a Built-Ins picker (bundled shapes, dice, boards, skyboxes) — plus a Room Controls Skybox picker (built-in + custom, applied to the room). At a game table, helpers see only the decks/objects they can spawn; boards, skyboxes and scenes are GM+. Add to Library (creation) is editor-only, and the publish/rename/delete controls only appear for admins — non-admin curation is server-refused as well. Admins can spawn private assets anywhere (handy for prepping a campaign); the public flag widens spawn rights, never curation rights.
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/
/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/models/
(trusted, shipped with the app — no upload path).
Upload a model as a prop (+ Object → Custom model…) or a board
(↷ Board → Upload). Models are normalized (props to a target size, boards to
fit the table), given a box collider from their measured bounds, and can be
tinted. Built-in model pieces (chess, checkers, go, coin, chip, token) use a
fixed per-piece scale and precomputed colliders so a set keeps its real
proportions. modelScale, modelRot, and the tint mode all live in
shared/pieces.js. See docs/ASSET_CREDITS.md for bundled-asset licensing (all CC0).
Built-in sound effects live in public/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/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.
Two kinds of account: a player (passwordless — a display name and an email (a unique id, not necessarily valid) plus a device token kept in the browser, created by quick-join) and a host (has a password). Anyone can join a room by code; only an approved host (or an admin) can create one.
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 (which sets a password). 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.
The full accounts / rooms / roles / admin / library-curation layer is built, and the
hardening pass is complete: admin-gated + 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
docs/ARCHITECTURE.md.
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.mdfor changes andRELEASING.mdfor how a release is cut.