One tldraw canvas per room, one Postgres row per room's worth of state, and a Discord login screen standing between your players and both. That's the whole pitch. Everything below is just the part where I convince you it also has locks on the right doors.
- Postgres. Any instance works — for local dev, one line:
docker run -d --name ttrpg-pg -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=ttrpg -p 5433:5432 postgres:17-alpine
- Create a Discord application at https://discord.com/developers/applications,
and add the redirect
http://localhost:4567/auth/discord/callbackunder OAuth2. Discord will not tell you if you got this wrong. It will just fail. - Copy
.env.exampleto.envand fill in:DISCORD_CLIENT_ID/DISCORD_CLIENT_SECRETADMIN_IDS— comma-separated Discord user IDs. Admins are auto-approved and get the/adminpage plus the "Make GM-only" button. Put yourself on this list. Obviously.COOKIE_SECRET— any long random string. Yes, that one, from your password manager's "generate" button.
pnpm dev→ http://localhost:4567. That's it. That's the setup.
Everyone logs in with Discord, because building your own auth in 2026 is a
choice, and not a good one. New users land on a waiting screen until an admin
approves them at /admin — that page auto-refreshes, the waiting screen polls
on its own, and nobody has to hit refresh like an animal.
Admins create rooms ({name, slug}) at /admin, each with its own per-user
allowlist. Players see and connect to only the rooms they're on; admins walk
into all of them, because admins made the rules. Canvases live at /r/<slug>
and persist as snapshots in Postgres, debounced two seconds after every
change — enough to not hammer the database, not enough for anyone to notice
the lag.
GM-only shapes. As an admin, select any shape or frame and hit
"Make GM-only." Non-admin clients hide it — and its children — through
tldraw's getShapeVisibility. This is UI-level hiding. The data still syncs
to every client sitting in that room; it's hidden, not secret, and if one of
your players opens devtools that's a conversation between the two of you, not
a bug report to me.
Cursors. Other people's cursors carry their Discord avatar and name, so you always know exactly whose token is about to get dragged into the lava by accident.
Dice. The 🎲 box in the top-right rolls Rollem
syntax — 2d6+3, 4d6kh3, 2d20kh1 + 5 for initiative, d20!, and so on.
Rolls happen server-side, which is the only part of this app that gets to be
paranoid: the result lands as a text shape at your viewport center, and
nobody's client-side Math.random ever gets a vote. The grammar itself is
vendored (MIT) in server/rollem/vendor/; regenerate the parser with
node server/rollem/build-parser.mjs if you ever touch it, which you won't.
Image and video uploads sit on disk in ./uploads (UPLOADS_DIR). Not S3.
Not a CDN. A folder. It's a home game, not a hyperscaler.
dev— dev server, frontend and backend, both on :4567build/start— production build and run