Self-hosting
One process, one port; TLS terminates at a reverse proxy. The shipped
docker-compose.yml runs the rooms node behind Caddy (automatic
Let’s Encrypt for your DOMAIN, WebSocket upgrades transparent) and serves
this static site from the same origin.
Quick start (docker compose)
Section titled “Quick start (docker compose)”git clone <rooms_sdk> && cd rooms_sdk# 1. DNS: point your domain's A/AAAA record at this machine.# 2. Secrets:cat > .env <<EOFDOMAIN=rooms.example.comACME_EMAIL=you@example.comGITHUB_TOKEN=<token with access to the private physics dependency>EOF# 3. Node config (see the reference below):cp deploy/node.example.json node.json # then edit secrets# 4. Launch:docker compose up -dWhat comes up:
| Service | Role |
| --- |
| rooms-node | the node binary (packages/node): /v1/* HTTP API + /play/<roomKey> WebSockets on one port, SQLite in the rooms-data volume |
| site | one-shot init container that builds this site and publishes dist/ into the shared rooms-site volume |
| caddy | TLS (HTTP/2 + HTTP/3), proxies /v1/* and /play/* to the node, serves everything else (docs, llms.txt, /spec/*) from the site volume |
The image is a multi-stage build: the build stage compiles the
ReactPhysics3D native library and the AOT bundle (dart build cli — native
assets linked by build hooks); the runtime is debian:bookworm-slim,
unprivileged, with libsqlite3.
Node config reference (node.json)
Section titled “Node config reference (node.json)”| Field | Type | Default / rule | Meaning |
|---|---|---|---|
publicBaseUrl |
string | — | Origin used to build room URLs (wss://…/play/<key>); must match the public domain behind Caddy |
nodeSecret |
string | — | HMAC key for join/creator tokens; generate 32+ random characters |
apiKeys |
object | — | Service keys (name → secret) seeded into the api_keys table; sent as Authorization: Bearer <key> |
dataDir |
string | /var/lib/rooms |
SQLite directory (persist it — join-token consumption and room pins survive restarts) |
listen.port |
int | 8787, 1024–65535 | Node listen port (plain HTTP; TLS is the proxy’s job) |
listen.address |
string | 0.0.0.0 |
Bind address |
allowedOrigins |
string[] | — | Origin allowlist enforced before the WebSocket upgrade |
limits.maxRooms |
int | 256 | Concurrent rooms; beyond → quota_exceeded |
limits.maxReservedCapacity |
int | 4096 | Sum of room capacities; beyond → quota_exceeded |
limits.idleTtlSeconds |
int | 300 | Idle rooms stop ticking but keep room + state in memory |
limits.connectionsPerIpPerMinute |
int | 60 | Pre-upgrade per-IP throttle |
joinTokenTtlSeconds |
int | 900 (60–86400) | Minted join-token lifetime |
movement |
bool | true |
Wire the reactphysics3d movement backend (false → movement rooms return unsupported_template) |
Config is fail-fast: unknown or out-of-range fields abort startup.
TLS via Caddy
Section titled “TLS via Caddy”deploy/Caddyfile:
{$DOMAIN} { encode zstd gzip handle /v1/* { reverse_proxy rooms-node:8787 } handle /play/* { reverse_proxy rooms-node:8787 } handle { root * /srv/site try_files {path} {path}/ file_server }}- Set
DOMAIN(and optionallyACME_EMAIL) in the compose environment; certificates are issued and renewed automatically. - HTTP/3 is enabled (
443/udpis published). - Node routes are explicit; everything else is the static site, so
https://your-domain/is the docs,https://your-domain/v1/…the API,https://your-domain/llms.txtthe agent index.
Deployment checklist (e.g. Hetzner)
Section titled “Deployment checklist (e.g. Hetzner)”- A server with Docker and ports 80/443 (TCP + UDP 443) open.
- DNS A/AAAA record for your domain → server IP (must resolve before Let’s Encrypt will issue).
.envwithDOMAIN,ACME_EMAIL,GITHUB_TOKEN(read access to the private reactphysics3d_dart dependency for the image build).node.jsonfromdeploy/node.example.json— newnodeSecret, realapiKeys, your game origins inallowedOrigins,publicBaseUrl=https://<DOMAIN>.docker compose up -d --build; checkdocker compose psanddocker compose logs caddy.- Smoke test:
curl -s https://<DOMAIN>/v1/rooms→401(Caddy + node + auth all working);https://<DOMAIN>/llms.txt→ this site. - Backups: the
rooms-datavolume (SQLite). Room state is memory-only by design; rooms are lazily recreated from their pinned config on the next join after a restart.
Running without Docker
Section titled “Running without Docker”cd packages/nodecp ../../deploy/node.example.json /tmp/node.json # then edit secretsdart run bin/node.dart --config /tmp/node.json # movement physics includedThe node handles SIGINT/SIGTERM by draining (rooms closed, peers get 1001). See the HTTP API reference for the endpoint contract and its “Node implementation notes”.
Docs elsewhere, node here (split origins)
Section titled “Docs elsewhere, node here (split origins)”This compose file serves the docs from the same origin as the node
(https://<DOMAIN>/), which is the single-origin convenience path. The
site is plain static output, so it can equally be deployed to Cloudflare
Pages (docs there, node here — see site/README.md for the wrangler and
git-integrated paths). That is two origins and nothing breaks:
- the site’s internal links are same-origin paths (
/docs/…,/llms.txt,/spec/…) — they resolve under whichever domain serves them; - the hosted API’s base URL is not hardcoded anywhere in the site: the
API reference documents
https://<your-node-domain>/v1/…, and each node deployment advertises its own origin viapublicBaseUrlinnode.json(it builds the room URLs clients connect to).
Generated from docker-compose.yml, deploy/Caddyfile and
deploy/node.example.json in the repository.