Files
tavolo/server
woggioni 7b3b38ccbc
CI / Build and push docker image (push) Successful in 4m20s
Rewrite the backend in Rust with Axum
Replace the Python/kaya server in server/ with a Rust Cargo workspace of
three crates mirroring the old layout: tavolo-platform (lobby API, live-game
store, websocket endpoint, deadline scheduler, stats/Elo, OIDC sessions),
tavolo-scopone (pure scopone scientifico rules plus the platform adapter)
and tavolo-app (composition root and the `tavolo` binary with
serve/migrate/backfill-elo).

The HTTP and WebSocket contracts, error messages, Redis key layout and
Postgres schema are unchanged, so the existing web/ frontend and in-flight
games keep working. Notable differences: sessions are JSON under
tavolo:session: (Python pickle sessions invalidate once), LOGGING_CONFIG is
ignored in favour of RUST_LOG, DATABASE_URL must be Postgres, sqlx migrations
replace aerich, and OpenTelemetry exports Rust process metrics instead of
CPython gc metrics (no Postgres/Redis client instrumentation).

Docker Compose and the Kubernetes manifest now run the single Rust image
(embedded migrations, /static and /assets served by the app); the release CI
keeps building server/Dockerfile. The full test suite (scopone rules,
platform store/deadlines/stats/websockets, OIDC login flow, scopone e2e) is
ported and runs with cargo test --workspace; Redis/Postgres integration tests
are gated on TEST_REDIS_URL/TEST_DATABASE_URL.
2026-10-02 13:54:55 +00:00
..

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 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)

# 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).

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 the exporter default
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.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.

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-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:<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'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:<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 (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:

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:

{"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/):

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 + cargo + slim 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.