A Drawful-style party game where teams of two share a canvas: each teammate is locked to one shade of the team colour, no talking allowed — coordinate through the drawing. Then every other team invents lies about what you drew, and everyone votes for the real prompt.
Built on the tldraw SDK with self-hosted
tldraw sync (@tldraw/sync-core) running in a plain Node
server with SQLite persistence. No Cloudflare required. Destined for 2draw.dwyer.co.za.
- Game loop (
src/server/game.ts): an in-memory state machine per game —lobby → drawing → (titling → voting → reveal) per drawing → scores. Phases advance lazily: every state poll (and every mutation) callstick(), which moves the phase when its deadline passes or everyone has acted. No server-side timers. Timings are env-overridable (DRAW_MS,TITLE_MS,VOTE_MS,REVEAL_MS) for testing. - Scoring: guess the real prompt = 1000 (+500 to the artists); each player fooled by your lie = 500 to your team.
- Sync (
src/server/rooms.ts): fastify +@tldraw/sync-core. OneTLSocketRoomper team per round (game__r2__t0), persisted to SQLite files in.rooms/— fresh round, fresh room, free canvas reset. Up to 4 teams, using tldraw's dark/light palette pairs (red, blue, green, violet); member 0 draws the dark shade, member 1 the light one. - Client (
src/client/): polls/api/game/:id/stateevery 1.5s and renders the matching screen. The canvas locks each player to their shade viasetStyleForNextShapesplus aregisterBeforeCreateHandlerbackstop;beforeDelete/beforeChangeside effects stop you erasing or moving your partner's strokes. Side-effect handlers checksource === 'user'so remote changes pass through untouched. At pens-down the canvas goes read-only andeditor.toImage()exports the team drawing as a PNG data URL to the server. - Games live in the URL hash:
/#some-game-id. No hash → a random game is created. A token insessionStoragemeans a refresh keeps your seat.
A simpler variant for playtesting the core "two people silently coordinating a drawing" mechanic. Two players share a canvas and a secret word (no talking, each locked to one shade); everyone else watches the canvas live and races to type the right guess. Drawers rotate each round so everyone gets a turn. First correct guesser and both drawers score.
Open any hash starting with coop, e.g. http://localhost:5757/#coop-test, in 3+ tabs.
Server lives in src/server/coop.ts (/api/coop/:id/...); client in src/client/Coop.tsx.
Both modes share the canvas, sync, colour-locking and helpers in src/client/shared.tsx.
Phase timings are env-overridable: COOP_DRAW_MS, COOP_REVEAL_MS.
npm install
npm run dev # server on :5858, client on :5757Open http://localhost:5757 in three or more tabs (seats are per browser session, and sessionStorage is per-tab, so every tab is a separate player). You need players on at least two teams to start. For quick testing, shorten the phases:
DRAW_MS=25000 TITLE_MS=20000 VOTE_MS=20000 REVEAL_MS=8000 npm run devVITE_TLDRAW_LICENSE_KEY=<your-key> npm run build # client → dist/
npm start # serves dist/ + sync on :5858Put a reverse proxy in front for TLS; it must proxy /connect (websocket) and the
/api routes. Caddy works with a plain reverse_proxy host:port (it handles ws
automatically). Caddy 2.6.x has a websocket bug — use 2.8+.
tldraw treats localhost/127.0.0.1 as development and renders without a key, but on
any production domain it silently refuses to render the editor without a license key
(you'll see "A license is required for production deployments" in the console and a blank
canvas). Get a key from https://tldraw.dev (free 100-day trial, no card) and pass it via
VITE_TLDRAW_LICENSE_KEY at build time — it's wired into <Tldraw licenseKey={...}> in
src/client/shared.tsx. Without a key the app only works on localhost.
Runs on the shared host oc alongside other Caddy sites. Node 20 (via nvm) under the
2draw.service systemd unit on port 3002; Caddy reverse-proxies 2draw.ritzademo.com →
localhost:3002. Server resilience: a process.on('uncaughtException') guard keeps a
single malformed sync message from crashing the whole process, and perMessageDeflate is
disabled on the websocket (some proxies drop the compression-accept header). The remaining
blocker to a live demo is the tldraw license key above.