Skip to content

Repository files navigation

dice

Play it — dice.invinite.tech

Two glowing neon dice beside the word dice in gold script

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.

Stack

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.

Develop

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 binary

End-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.

Configure (backend env)

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

Telemetry (optional, standard OTel)

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.

Surviving a restart (optional)

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.

Abuse protection (public endpoint)

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 set X-Real-IP / append X-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.

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 :main on every push to main, and :<version> + :latest on a v* git tag, plus rolling aliases :1 (follows the latest v1.x.y) and :1.0 (latest v1.0.x) so a server can pin a major/minor and auto-update on releases without IaC changes.
  • Port: 3040 (change with DICE_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 to https://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:3040 so the proxy is the only public listener, and DICE_TRUST_PROXY=true so per-IP limits see the real client (see Abuse protection). /ws is 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

About

Realtime, no-auth, multiplayer 3D dice roller — Rust axum + SvelteKit

Resources

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages