# tavolo (backend) The backend for a multiplayer card-game platform, written in Rust with [Axum](https://github.com/tokio-rs/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 the `GameEngine` contract 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 the `tavolo` binary (`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) ```sh # 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: ```sh 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__PROVIDER` | `default` ¹ | Provider name (required when OIDC is configured): used in the login URL `/auth//login` and to namespace its session keys. Unique, `[A-Za-z0-9_-]+` | | `OIDC__ISSUER` | `http://localhost:8180/tavolo` ¹ | OIDC issuer URL | | `OIDC__CLIENT_ID` | `tavolo` ¹ | OIDC client id | | `OIDC__CLIENT_SECRET` | unset | OIDC client secret; leave unset for public clients | | `OIDC__REDIRECT_URI` | `http://localhost:8080/auth//callback` | Login callback URL; must be registered at the provider | | `OIDC__POST_LOGIN_REDIRECT` | `/` | Where the browser is sent after login | | `OIDC__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__*` 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//login` with its own callback URL and session namespace: ```sh 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.id` is a truncated SHA-256 of the session id (the cookie is a credential, so it is never exported verbatim) and `user.id` is the OIDC `sub`, present once logged in. Database-client auto-instrumentation is not included. - **Metrics**: the `http.server.request.duration` histogram, plus `tavolo.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:` — the whole session as JSON: the platform-owned envelope (`id`, `game_type`, `join_code`, seats, timestamps) plus the game-specific `state` blob, 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:` — the 6-character join code → game id index. - `tavolo:game::lock` — a short-lived lock serializing every mutation. - `tavolo:game::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's `fire_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 the `tavolo.ongoing_games` gauge. Sessions live under `tavolo:session:` 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 (`result` JSON — for scopone: both teams' final scores, winner, target score, hands played and the per-hand audit). - `match_player` — one row per participant: the OIDC `sub`, 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 (`details` JSON). 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: ```sh 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: true` is 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: ```json {"action": "play", "card": "07D", "capture": ["02D", "05C"]} {"action": "play", "card": "07D"} {"action": "ack"} {"action": "state"} ``` - `card` is the card you play, rendered as `RRSUIT` (`01A`..`10D`; suits `D` denari, `C` coppe, `S` spade, `B` bastoni — e.g. `07D` is the settebello). - `capture` lists the table cards to take. When a capture is legal it is mandatory to provide one; when no capture exists it must be omitted. - `ack` acknowledges the hand-end scoring summary (see below). The next hand is dealt once all four players have acknowledged, or automatically after `HAND_ACK_TIMEOUT_SECONDS`. - `state` asks 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 `napola` creation 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_pigliatutto` creation 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_score` creation option) with a clear lead; a tie at or above the target is broken by another hand. ## Development Backend (from `server/`): ```sh 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: ```sh 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: ```sh 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: ```sh 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`. |