Build the backend as a fully static musl binary (rust:1-alpine + x86_64-unknown-linux-musl) and run it in a scratch image with only the binary and the compiled frontend: 32 MB instead of ~110 MB, no shell, package manager or libc, running as the numeric user 10001:10001. The binary moves to /usr/local/bin/tavolo so `tavolo migrate` and `tavolo serve` resolve on PATH in compose and Kubernetes. TLS for OIDC and Postgres validates against Mozilla's roots embedded in the binary by webpki-roots, so no certificate files are needed; the OTLP client is built without a TLS backend, so OTEL_EXPORTER_OTLP_ENDPOINT must be http:// (documented). The Docker HEALTHCHECK is removed since scratch has no curl: Kubernetes probes /api/health over HTTP and Compose gates on its dependency checks. `strip = "symbols"` trims the release binary. Validated with docker compose up --build: migrations, sessions, lobby creation, the OIDC discovery redirect and the SPA all work, proving musl DNS resolution for the postgres/redis/mockoauth service names.
24 KiB
tavolo (backend)
The backend for a multiplayer card-game platform, written in Rust with Axum. It is a Cargo workspace of three crates mirroring the original Python layout:
crates/tavolo-platform/— everything game-independent: the lobby API, the live-game store, the WebSocket endpoint, the deadline scheduler, match persistence and the Elo leaderboards, plus theGameEnginecontract games implement.crates/tavolo-scopone/— scopone scientifico, the four-player, fixed-partnership variant of the classic Italian card game: pure rules plus the platform adapter.crates/tavolo-app/— the composition root: environment-driven settings, OIDC login, sessions, CORS, OpenTelemetry, SPA hosting and thetavolobinary (serve/migrate/backfill-elo).
Players authenticate with the configured OIDC provider. Live game state
is kept in Redis (with real-time play over WebSocket), and completed
matches — every participant and the game-specific result — are persisted to
Postgres for history and leaderboard statistics. The HTTP/WebSocket
contract is byte-compatible with the SPA in ../web/.
Quick start (Docker Compose)
# From the repository root (docker-compose.yml lives there):
docker compose up --build
The stack starts Postgres, Redis, a mock OIDC provider
(navikt/mock-oauth2-server) with four ready-made players
(alice, bob, carol, dave), a one-shot database migration service
(tavolo migrate) and the app itself on http://127.0.0.1:8080. The same
container also serves the Sycamore/WASM frontend (built from ../web/ by the
Docker image), so the UI is available at that address.
Because the browser and the app both talk to the OIDC issuer at
http://mockoauth:8180/tavolo, add a host entry once:
echo "127.0.0.1 mockoauth" | sudo tee -a /etc/hosts
Then log in: GET http://localhost:8080/auth/login (a provider-agnostic
alias that redirects to the first configured provider), type any username
(e.g. alice) in the interactive login, and you are redirected back with a
session cookie. That cookie authenticates both the REST API and the
WebSocket endpoint.
OpenAPI documentation is served at /api/docs (/api/openapi.json).
The release image is a scratch image containing a fully static musl
binary and the compiled frontend: no shell, package manager or libc. It runs
as the numeric user 10001:10001 and has no Docker HEALTHCHECK (scratch has
no curl); Kubernetes probes /api/health over HTTP, and Compose gates on
the Postgres/Redis/OIDC dependency checks. To debug a running container use
docker debug (Docker Desktop) or kubectl debug with an ephemeral
container.
Configuration
All configuration comes from environment variables (see .env.example):
| Variable | Default | Description |
|---|---|---|
DATABASE_ENGINE |
postgres |
Database DSN scheme/driver (Postgres only) |
DATABASE_HOST |
localhost |
Postgres host |
DATABASE_PORT |
unset | Postgres port; omitted from the DSN when empty (driver default, 5432) |
DATABASE_NAME |
tavolo |
Postgres database name |
DATABASE_USER |
tavolo |
Postgres user |
DATABASE_PASSWORD |
password |
Postgres password |
DATABASE_OPTIONS |
unset | Extra DSN query parameters, e.g. ssl=require |
DATABASE_URL |
unset | Full-DSN override; when set, the DATABASE_* parts above are ignored (managed-DB URLs) |
REDIS_URL |
unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
OIDC_<n>_PROVIDER |
default ¹ |
Provider name (required when OIDC is configured): used in the login URL /auth/<provider>/login and to namespace its session keys. Unique, [A-Za-z0-9_-]+ |
OIDC_<n>_ISSUER |
http://localhost:8180/tavolo ¹ |
OIDC issuer URL |
OIDC_<n>_CLIENT_ID |
tavolo ¹ |
OIDC client id |
OIDC_<n>_CLIENT_SECRET |
unset | OIDC client secret; leave unset for public clients |
OIDC_<n>_REDIRECT_URI |
http://localhost:8080/auth/<provider>/callback |
Login callback URL; must be registered at the provider |
OIDC_<n>_POST_LOGIN_REDIRECT |
/ |
Where the browser is sent after login |
OIDC_<n>_POST_LOGOUT_REDIRECT |
/ |
Where the browser is sent after logout |
GAME_TTL_SECONDS |
86400 |
Sliding TTL of a live game in Redis |
HAND_ACK_TIMEOUT_SECONDS |
30 |
Seconds the scopone between-hands scoring summary waits for acknowledgements (wired into the ScoponeEngine) |
TURN_TIMEOUT_SECONDS |
30 |
Seconds a scopone player has to play before the server plays a random legal card for them (wired into the ScoponeEngine) |
DEADLINE_HEARTBEAT_MS |
30000 |
Backstop poll interval of the deadline consumer; enqueued deadlines are announced over pub/sub and wake consumers immediately, so this only bounds recovery from lost notifications or a dead worker |
LOGGING_CONFIG |
unset | Accepted for compatibility; ignored (the Rust server logs via RUST_LOG) |
RUST_LOG |
debug (app), INFO+ deps |
tracing-subscriber env filter, e.g. tavolo=info,sqlx=warn |
CORS_ALLOW_ORIGINS |
unset | Comma-separated origins allowed for cross-origin requests, or * for any. CORS is disabled unless this or CORS_ALLOW_ORIGIN_REGEX is set |
CORS_ALLOW_ORIGIN_REGEX |
unset | Regex (fullmatch) additionally matched against request origins, e.g. https://tavolo-[a-z0-9-]+\.vercel\.app |
CORS_ALLOW_METHODS |
GET |
Comma-separated methods allowed for cross-origin requests, or * for all |
CORS_ALLOW_HEADERS |
unset | Comma-separated request headers allowed in cross-origin requests, or * to mirror back the requested ones. The CORS-safelisted headers are always allowed |
CORS_ALLOW_CREDENTIALS |
false |
1/true/yes/on allow cookies/credentials on cross-origin requests |
CORS_EXPOSE_HEADERS |
unset | Comma-separated response headers exposed to the browser |
CORS_MAX_AGE |
600 |
Seconds browsers may cache the preflight response |
OTEL_ENABLED |
false |
1/true/yes/on enable OpenTelemetry traces, metrics and logs (compiled in by default) |
OTEL_SERVICE_NAME |
tavolo |
service.name resource attribute of the exported telemetry |
OTEL_EXPORTER_OTLP_ENDPOINT |
unset | Base URL of an OTLP/HTTP collector (e.g. http://localhost:4318; unset uses that default). Must be http://: the OTLP client is built without a TLS backend |
OTEL_EXPORTER_OTLP_HEADERS |
unset | Comma-separated key=value headers sent to the collector (e.g. authentication) |
OTEL_EXCLUDED_PATHS |
/api/health |
Comma-separated paths excluded from tracing and metrics (exact matches) |
OTEL_METRIC_EXPORT_INTERVAL_MILLIS |
60000 |
How often the OTLP metric reader exports |
OTEL_METRICS_POLL_INTERVAL_SECONDS |
15 |
How often the session/game count gauges are refreshed |
APP_HOST / APP_PORT |
0.0.0.0 / 8080 |
Bind address |
STATIC_DIR |
web/dist |
Directory holding the compiled frontend (the Docker image sets /app/web/dist) |
¹ When no OIDC_<n>_* variable is set at all, a single provider named
default pointing at the dev mock IdP is used. Indices must be contiguous
from 0 (OIDC_0_*, OIDC_1_*, ...).
Multiple OIDC providers
Providers are configured in indexed blocks; each becomes its own login route
/auth/<provider>/login with its own callback URL and session namespace:
OIDC_0_PROVIDER=google
OIDC_0_ISSUER=https://accounts.google.com
OIDC_0_CLIENT_ID=...
OIDC_0_CLIENT_SECRET=...
OIDC_0_REDIRECT_URI=https://tavolo.example.com/auth/google/callback
OIDC_1_PROVIDER=github
OIDC_1_ISSUER=https://github.com/login/oauth
OIDC_1_CLIENT_ID=...
OIDC_1_CLIENT_SECRET=...
OIDC_1_REDIRECT_URI=https://tavolo.example.com/auth/github/callback
The login page reads GET /api/auth/providers and shows one button per
provider. GET /auth/login redirects to the first provider (useful for
bookmarks and scripts), and GET /auth/logout signs out whichever provider
the session is logged in with. Sessions hold a single identity: logging in
with another provider replaces the previous one.
Logging
The Rust server logs through tracing, configured with RUST_LOG
(tracing-subscriber env-filter syntax). The default filter is debug for
the application with noisy dependencies capped (sqlx, redis, hyper,
reqwest at WARN). LOGGING_CONFIG (a Python logging.config.dictConfig
YAML path) is accepted so existing deployments do not break, but its value is
ignored with a warning.
Lifecycle and business events are logged at INFO (game created/joined, websocket connections, match results, auto-plays); per-move and store detail at DEBUG.
OpenTelemetry
With OTEL_ENABLED set, the app exports over OTLP/HTTP:
- Traces: one span per HTTP request and one per WebSocket connection,
tagged with the request's session and user:
session.idis a truncated SHA-256 of the session id (the cookie is a credential, so it is never exported verbatim) anduser.idis the OIDCsub, present once logged in. Database-client auto-instrumentation is not included. - Metrics: the
http.server.request.durationhistogram, plustavolo.active_users(sessions in the session store),tavolo.ongoing_games(live matches not finished yet), and process metrics:process.cpu.time,process.cpu.utilization,process.memory.usage,process.memory.virtual. The Python build's CPython gc metrics have no Rust equivalent. - Logs: tracing events forwarded to OTLP (OpenTelemetry's own records are filtered out to avoid export loops).
The session/game gauges are polled every
OTEL_METRICS_POLL_INTERVAL_SECONDS (Redis-backed stores report
cluster-wide counts, in-memory stores the process's own). With multiple
replicas each process reports the same global counts: aggregate with max,
not sum.
The OTLP client is built without a TLS backend, so
OTEL_EXPORTER_OTLP_ENDPOINT must use http://; terminate TLS in front of
the collector (or run a local sidecar) if the collector is remote. The
OIDC/Postgres clients do not use this client and validate HTTPS against
Mozilla's roots embedded in the binary (no certificate files are needed, and
private CAs are not trusted by them).
Data model
Redis (live games)
tavolo:game:<uuid>— the whole session as JSON: the platform-owned envelope (id,game_type,join_code, seats, timestamps) plus the game-specificstateblob, which only the registered engine interprets. Sliding TTL (GAME_TTL_SECONDS). The layout is identical to the original Python implementation, so in-flight games survive the backend swap.tavolo:code:<JOINCODE>— the 6-character join code → game id index.tavolo:game:<uuid>:lock— a short-lived lock serializing every mutation.tavolo:game:<uuid>:events— a pub/sub channel carrying "state changed" signals; every open WebSocket reloads the session and pushes the personalized view to its player.tavolo:deadlines— a sorted set (score = due timestamp) of pending timeouts (scopone's turn auto-plays and hand-end auto-continues). Every worker runs a consumer that fires due entries under the per-game lock by calling the engine'sfire_deadline, so timeouts do not depend on any player being connected and survive the death of any worker (delivery is at-least-once; engines revalidate the entry's token against the live state before firing).tavolo:deadlines:events— a pub/sub channel announcing newly enqueued deadlines; it wakes every worker's consumer immediately, while the heartbeat poll remains as a backstop for lost signals.tavolo:games:ongoing— sorted set of running games for thetavolo.ongoing_gamesgauge.
Sessions live under tavolo:session:<id> as JSON (the Python server used
pickled values under kaya:session:; existing sessions are invalidated once
on cutover, users log in again).
Postgres (statistics, via sqlx migrations)
match— one row per finished match: the game played (game_type, indexed so statistics can be scoped per game), start/finish timestamps, and the game-specific outcome as reported by the engine (resultJSON — for scopone: both teams' final scores, winner, target score, hands played and the per-hand audit).match_player— one row per participant: the OIDCsub, display name, seat, game-defined team label (nullable), whether they won, the points they scored, the Elo change the match produced (elo_delta) and game-specific extras (detailsJSON). Unique per(match, user_sub).player_rating— current Elo rating per(user_sub, game_type), with the number of rated matches played.
When a match ends, the engine's result is written transactionally to Postgres (once, guarded by a flag on the session); the finished session stays in Redis until its TTL expires so clients can still fetch the final board.
Elo ratings
Players carry a chess-style Elo rating per game type (tavolo.platform.elo):
everyone starts at 1500, a team's rating is the mean of its members, and the
standard formula E = 1 / (1 + 10 ** ((R_opp - R_team) / 400)) with K = 32
decides how many points the match result moves — the same delta for every
member of a team, zero-sum between the two teams, with Python-compatible
banker's rounding. Ratings update in the same transaction as the match
result. To recompute every rating from the recorded match history:
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo \
cargo run -p tavolo-app -- backfill-elo
# or, in the container image:
docker compose run --rm tavolo tavolo backfill-elo
REST API
All endpoints except /api/health, /api/docs, /api/openapi.json,
/api/game-types and /api/leaderboard require authentication.
| Method | Path | Description |
|---|---|---|
GET |
/api/game-types |
The games the platform can host (for the creation dropdown), each with its options_schema |
POST |
/api/games |
Create a lobby game. Optional body {"game_type": "scopone_scientifico", "options": {"target_score": 11, "napola": true, "asso_pigliatutto": false}}. Returns the lobby ({id, join_code, players, seats_open, ...}) |
POST |
/api/games/join |
Join with {"code": "ABC123"}. The last player triggers the start and gets the game view back |
GET |
/api/games/preview?code=ABC123 |
Pre-join summary of the lobby behind a code: game name/description, seated players, open seats and the engine-echoed options. 404 when the code is unknown |
GET |
/api/games/{id} |
Personalized snapshot (only your own hand is visible) |
GET |
/api/me/matches |
Cursor-paginated match history with the engine's result and per-player Elo deltas (?limit=&cursor=&game_type=) |
GET |
/api/me/ratings |
The caller's Elo rating per game type |
GET |
/api/leaderboard |
Elo rating, aggregated wins / matches / points per player, sorted by Elo (?game_type=) |
WebSocket protocol
Connect to /ws/games/{game_id} with your session cookie. Only seated
players are accepted; the upgrade is refused before the handshake completes
with an HTTP status mirroring GET /api/games/{id}: 401 unauthenticated,
404 unknown game, 403 not seated. (Browsers report a refused handshake as
an abnormal close — there is no close code to read.)
Server → client messages are JSON objects with a type:
{"type": "state", "game": {...}}— personalized game view. Your own hand is included; other players expose only their card counts.your_turn: trueis present when it is your move.{"type": "game_over", "scores": {"A": 11, "B": 7}, "winner": "A"}{"type": "error", "code": "illegal_move", "message": "..."}
Client → server messages are JSON objects with an action. The platform
handles state (resend the snapshot) itself; every other action is
dispatched to the session's game engine with the rest of the message as its
payload. For scopone scientifico:
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"action": "ack"}
{"action": "state"}
cardis the card you play, rendered asRRSUIT(01A..10D; suitsDdenari,Ccoppe,Sspade,Bbastoni — e.g.07Dis the settebello).capturelists the table cards to take. When a capture is legal it is mandatory to provide one; when no capture exists it must be omitted.ackacknowledges the hand-end scoring summary (see below). The next hand is dealt once all four players have acknowledged, or automatically afterHAND_ACK_TIMEOUT_SECONDS.stateasks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players.
Turn timeout
The state carries a turn_deadline while a hand is being played. If the
player on turn does not move before it, the server plays a random legal card
for them (picking one of the legal captures at random when a capture is
required), so a disconnected or idle player cannot stall the match. The
timeout is TURN_TIMEOUT_SECONDS (default 30); the auto-played move is
broadcast like any other. Deadlines fire from the shared tavolo:deadlines
queue, not from timers tied to client connections, so the match keeps
progressing even with every player disconnected.
Hand-end summary
When a hand finishes but the match continues, the game enters the
hand_end phase instead of dealing immediately: the state carries
last_hand (a full scoring breakdown with an award map naming the team
that won each category), the acknowledged seats and a hand_end_deadline.
The frontend renders this as a screen every player must dismiss. A play
attempted in this phase is rejected with an illegal_move error.
Rules implemented
- 40-card Italian deck, ten cards per player, empty table at hand start.
- A card captures a single card of equal rank (mandatory when present) or a combination of table cards whose ranks sum to its own.
- Emptying the table scores a scopa (+1), except on the last play of a hand. Remaining table cards go to the last player who captured.
- Hand points:
carte(most captured cards),denara(most diamonds),settebello(7♦),primiera(best 7/6/5/4 per suit, all four suits required), plus one point per scopa. Ties award nothing. - Optional napola rule (per-game
napolacreation option, default on): the longest run of consecutive denari starting from the ace scores one point per card once it reaches three cards (A-2-3 = 3, A-2-3-4 = 4, …). A team that captures the whole denari suit (ace to king) wins the match instantly, regardless of the score. - Optional asso pigliatutto rule (per-game
asso_pigliatuttocreation option, default off): an ace played when no equal rank is on the table sweeps every card on the table — or captures itself when the table is empty. The capture never scores a scopa, but it counts as the last capture, and the mandatory equal-rank capture still applies when another ace is on the table. - The match ends when a team reaches the target score (default 11,
configurable per game via the
target_scorecreation option) with a clear lead; a tie at or above the target is broken by another hand.
Development
Backend (from server/):
cargo test --workspace # unit + integration suites
cargo run -p tavolo-app # = tavolo serve (needs the env below)
cargo run -p tavolo-app -- migrate
For a local run without Docker:
DATABASE_URL=postgres://tavolo:password@localhost:5432/tavolo \
REDIS_URL=redis://localhost:6379/0 \
cargo run -p tavolo-app
Tests run fully in-process: in-memory stores, a DummyEngine toy game for
the platform suite, a mock OIDC provider for the login flow, and ephemeral
TCP servers for WebSocket coverage. The Redis- and Postgres-backed tests are
gated on TEST_REDIS_URL / TEST_DATABASE_URL and skipped when unset.
Frontend (from web/), with the backend running on :8080:
cd web
trunk serve # SPA on http://localhost:8000, /api /auth /ws proxied
For the login redirect to land back on the dev server, run the backend with:
OIDC_0_PROVIDER=default \
OIDC_0_ISSUER=http://localhost:8180/tavolo \
OIDC_0_CLIENT_ID=tavolo \
OIDC_0_REDIRECT_URI=http://localhost:8080/auth/default/callback \
OIDC_0_POST_LOGIN_REDIRECT=http://localhost:8000/ \
OIDC_0_POST_LOGOUT_REDIRECT=http://localhost:8000/ \
cargo run -p tavolo-app
Card images are committed under web/assets/cards/; web/fetch-cards.sh
re-downloads them if needed.
Migrations
The Postgres schema is owned by embedded sqlx migrations in
crates/tavolo-app/migrations/, applied by tavolo migrate (the
db-migrate compose service and the Kubernetes init container). The
migration is idempotent (CREATE TABLE IF NOT EXISTS) and matches the
original aerich-created schema, so it can be applied to an existing database
without touching data; the aerich bookkeeping table is left alone. To
change the schema, add a new numbered .sql file next to the baseline and
run cargo test -p tavolo-app (the migration is embedded at compile time).
Layout
crates/tavolo-platform/ # game-independent platform (own crate + tests)
src/
├── engine.rs # the platform<->game contract
├── registry.rs # game id -> engine lookup
├── errors.rs # GameError + platform Error
├── store.rs # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.rs # connection-independent timeout scheduler
├── session.rs # HTTP sessions (in-memory / Redis) + middleware
├── oidc.rs # OIDC discovery, PKCE, JWKS validation, logout
├── auth.rs # session -> user helpers
├── stats.rs # match persistence + history/leaderboard queries + Elo
├── models.rs # sqlx row models
├── elo.rs # chess-style Elo math (1500 start, K=32)
├── pagination.rs # keyset (cursor) pagination
├── http.rs # JSON request/response helpers
├── platform.rs # the collaborators shared by all endpoints
├── routes/ # health / me / auth / games / stats / openapi
└── ws.rs # WebSocket live-play endpoint (generic envelope)
crates/tavolo-scopone/ # scopone scientifico game (own crate + tests)
src/{state,engine,plugin,errors}.rs
crates/tavolo-app/ # deployable app: composition root (own crate)
src/{main,app,config,cors,logging,static_files,telemetry}.rs
migrations/ # embedded sqlx migrations
dev/mockoauth/ # dev OIDC provider image (compose)
Dockerfile # web-builder + musl cargo + scratch runtime
.env.example
Differences from the original Python server
The Python implementation that lived in this directory was replaced by the Rust one; it remains available in git history. Behavioral compatibility is intentional and tested (same JSON payloads, status codes, error messages, Redis layout and Postgres schema), with these documented differences:
| Area | Behavior |
|---|---|
| Sessions | Stored as JSON under tavolo:session: (Python: pickle under kaya:session:). Existing sessions are invalidated once on cutover; users log in again. |
| Logging | LOGGING_CONFIG is accepted but ignored; use RUST_LOG. |
| Database | Postgres only (DATABASE_URL=sqlite://... is rejected). |
| OpenTelemetry | Rust process metrics replace the CPython gc metrics; no Postgres/Redis client auto-instrumentation. |
| Deadline members | Compact JSON instead of Python's spaced json.dumps; stale duplicates are harmless (engines revalidate tokens). |
| CORS | Hand-written middleware replicating kaya-cors / Starlette CORSMiddleware semantics, including 400 Disallowed CORS ... preflight rejections. |
| CLI | tavolo migrate / tavolo backfill-elo replace aerich upgrade / python -m tavolo.platform.backfill_elo. |