A realtime, no-auth, multiplayer dice roller. The first visitor creates a game and shares a short code / QR; everyone else joins the same live session and rolls in turns. Rolls stream to every screen and are kept in a running history for the game's lifetime. Games auto-expire when idle — a dead code just 404s.
- Four games, picked by the host — a plain turn-based roller, Liar's Dice (hidden dice, bid or call the bluff), Yatzy (Nordic, 15-box scorecard) and Farkle (push your luck to 10 000). The server scores all of them.
- Bots to fill the table — easy, hard, or sneaky; anyone in the room can add or remove one, and they play their turns on their own.
- Realistic 3D dice — three.js + cannon-es physics; ivory / obsidian / ruby /
emerald / gold and the four elementals (fire / water / air / earth), plus
nixie tube dice via
@glowbox. - On-device sound — dice clacks synthesized with the Web Audio API.
- Throw it — flick with the mouse on desktop, shake the phone on mobile (DeviceMotion), or just tap.
- Light / dark / auto UI theme; room-wide dice theme and table surface.
- Drag to reorder the turn order; change the number of dice live.
- Installable (PWA), works in English and Suomi, and tells you what broke instead of showing a blank page.
Rust axum backend (in-memory game rooms + one WebSocket per game, no DB)
embedding a SvelteKit SPA. Shipped as a single arm64 container. See
CLAUDE.md for the architecture and invariants.
just dev # backend on :3040 (bacon), SPA on :5173 (vite, proxied)Open two browser windows, create a game in one, join with the code in the other.
just check # clippy + rustfmt + yarn validate + the bundle's browser floor
just test # cargo + vitest
just build # SPA → dist/, then the release binaryEnd-to-end specs drive the real stack (backend serving the built SPA):
cd frontend && yarn e2e after a just build. The spawned-binary Rust
integration tests are cargo test -p dice-integration -- --ignored.
Assets are committed, regenerated on demand: just icons (PWA icons, from
frontend/static/*.svg — needs librsvg + imagemagick) and just og (the link
preview at the top of this file, screenshotted from the live lobby sign).
Requires Rust, Node (see frontend/.node-version), just, and bacon.
| Var | Default | Meaning |
|---|---|---|
DICE_BIND |
0.0.0.0:3040 |
Listen address |
DICE_TTL_SECS |
7200 |
Idle lifetime of a game before reap (≥ 1). Long is fine — see DICE_MAX_ROOMS, which keeps memory bounded regardless; the deployment runs 86400 so a link shared in the evening still works the next day |
DICE_MAX |
8 |
Max dice per roll |
DICE_MAX_ROOMS |
5000 |
Max concurrent game rooms (bounds memory). At the cap a new game evicts the least-recently-active room with nobody connected; if every room is occupied, create returns 503 |
DICE_MAX_PLAYERS |
16 |
Max players per room |
STATIC_DIR |
./dist |
Built SPA to serve (prod) |
DICE_TRUST_PROXY |
false |
Trust X-Forwarded-For/X-Real-IP for per-IP limits — see below |
DICE_RL_CREATE_PER_MIN |
10 |
Per-IP room creations / minute (also the burst) |
DICE_RL_JOIN_PER_MIN |
60 |
Per-IP joins / minute (also the burst) |
DICE_RL_CLIENT_ERR_PER_MIN |
6 |
Per-IP browser error reports / minute (POST /api/client-error) |
DICE_WS_PER_IP |
24 |
Max concurrent WebSockets per IP |
DICE_MAX_WS |
20000 |
Global cap on concurrent WebSockets |
DICE_WS_MSGS_PER_SEC |
20 |
Per-connection inbound message budget / sec (burst 2×) |
DICE_BOT_DELAY_MS |
1000 |
Base bot "thinking" delay, ms (jittered; reveal pause ~3–4×) |
DICE_STATE_FILE |
(unset) | Path to persist games across a graceful restart — see below |
The backend exports OpenTelemetry metrics (games created, joins, rolls per
mode, bots added, live rooms/sockets, reaps, client errors by kind) and logs
(warn!/error! lines) over OTLP/HTTP when — and only when — the standard
OTEL_EXPORTER_OTLP_ENDPOINT (or a signal-specific
OTEL_EXPORTER_OTLP_{METRICS,LOGS}_ENDPOINT) is set. All knobs are the standard
OTEL_* env vars (OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_HEADERS,
OTEL_METRIC_EXPORT_INTERVAL, …); there are no app-specific telemetry
settings. Unset → telemetry is a complete no-op (no exporter thread, no
sockets). No traces are exported.
Log export is what makes browser-side failures diagnosable: when the SPA can't
start (a browser too old for the bundle, blocked site data, a dead chunk) it
posts a small report to POST /api/client-error, which becomes a
dice.client.errors{kind} counter plus a log record carrying the sanitized
message, URL and User-Agent. Without it, a blank page on someone else's phone is
unfalsifiable. The endpoint is un-authed like the rest of the app, so it's per-IP
rate limited (DICE_RL_CLIENT_ERR_PER_MIN), capped at a 1 KiB body, restricted
to a closed set of kind values, and every text field is truncated and stripped
of control characters before it's logged.
By default a restart drops every game (in-memory, ephemeral). Set
DICE_STATE_FILE to a writable path and, on a graceful shutdown
(SIGTERM/SIGINT — what a deploy or reboot sends), the live games are flushed to
that JSON file and reloaded (then the file is consumed) on the next boot;
reconnecting clients re-authenticate with their stored token and resume. Notes:
- Graceful only — a hard crash / OOM-kill / power loss loses everything (there is no periodic checkpoint).
- The file holds secret player tokens (that's what lets clients resume). It's
written
0600; keep it on a non-public path. In a container the path must be a mounted volume — the container filesystem is replaced on each deploy. - Version-tagged: a file written by an incompatible older build is discarded (those games are lost) rather than mis-read.
dice is un-authed, so it self-limits per client IP: a token bucket on room
creation + join (returns 429), a per-IP + global cap on concurrent
WebSockets, a per-connection message budget (kills broadcast amplification), and
a 16 KiB request-body cap (413). Room / player / dice / TTL caps above bound
total memory. These are defense-in-depth — a real DDoS is still the edge's job.
DICE_TRUST_PROXY is the load-bearing switch. Per-IP limits key on the
client IP, which the app can only see correctly if it knows whether a proxy is in
front:
- Behind a reverse proxy (TLS terminator / Traefik / nginx): set
DICE_TRUST_PROXY=true. Otherwise every request looks like it comes from the proxy and all clients share one bucket (self-DoS). The proxy must setX-Real-IP/ appendX-Forwarded-For(Traefik and nginx do by default). - Directly exposed (no proxy): leave it
false. Trusting the header when anyone can set it lets a client forge its IP to dodge the limits.
Per-IP defaults are deliberately NAT-friendly (a venue full of phones can share
one public IP) — raise DICE_WS_PER_IP / DICE_RL_JOIN_PER_MIN for a large
shared-network crowd, lower them to tighten a hostile-facing deploy.
dice ships as a single linux/arm64 container (Dockerfile → scratch):
the binary serves the embedded SPA and the API + WebSocket from one origin,
so a deploy just runs the image and routes to it. Deployment-agnostic contract:
- Image:
ghcr.io/eetu/dice— CI publishes:mainon every push tomain, and:<version>+:lateston av*git tag, plus rolling aliases:1(follows the latestv1.x.y) and:1.0(latestv1.0.x) so a server can pin a major/minor and auto-update on releases without IaC changes. - Port:
3040(change withDICE_BIND). - Health:
GET /status→{ service, version, rooms }, unauthenticated — use it for liveness probes. - Public / un-gated: no login, no forward-auth — anyone with a room code
joins (see
SECURITY.md). Terminate TLS at the edge, but do not put it behind an auth proxy. - Stateless: all state is in memory — no database, no volumes, nothing to
back up. A restart drops every game, unless you opt into
DICE_STATE_FILE(see Surviving a restart), which needs one small writable path. - Link previews: the shell's Open Graph tags (
frontend/src/app.html) carry absolute URLs pinned tohttps://dice.invinite.tech— OG requires absolute ones and there's no SSR to fill in the live origin, so point them at your own host if you deploy this elsewhere. - Behind a reverse proxy: set
DICE_BIND=127.0.0.1:3040so the proxy is the only public listener, andDICE_TRUST_PROXY=trueso per-IP limits see the real client (see Abuse protection)./wsis same-origin, so a normal HTTP proxy that forwards WebSocket upgrades needs no special config.
Run it directly:
docker run -p 3040:3040 ghcr.io/eetu/dice:main # → http://localhost:3040