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