Move the game-independent machinery (lobby, live-game store, websocket, deadline scheduler, match history, leaderboards) into a new tavolo-platform distribution behind a GameEngine contract, the scopone scientifico rules plus a platform adapter into tavolo-scopone, and keep only the composition root in tavolo-app. The three distributions share the tavolo namespace (PEP 420, kaya-style monorepo). Match history becomes fully generic: Match carries the engine's result JSON and MatchPlayer points/details instead of scopone-shaped team columns (migration 3 backfills existing rows). Lobby creation takes an opaque per-game options object and websocket actions dispatch to the session's engine. Tests: platform suite runs against a DummyEngine toy game, scopone keeps the rules tests plus new adapter tests, server/tests covers the wired stack end to end (194 tests, was 143).
tavolo (backend)
The backend for a multiplayer card-game platform, built on the
kaya framework. It is a monorepo of three distributions
sharing the tavolo namespace (see
packages/tavolo-platform/README.md
and packages/tavolo-scopone/README.md):
tavolo-platform(packages/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.tavolo-scopone(packages/tavolo-scopone/) — scopone scientifico, the four-player, fixed-partnership variant of the classic Italian card game: pure rules plus the platform adapter.tavolo-app(src/tavolo/, thispyproject.toml) — the composition root wiring the platform to the scopone game, with the environment-driven settings and the SPA shell.
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.
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,
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, 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 |
DATABASE_HOST |
localhost |
Postgres host |
DATABASE_PORT |
unset | Postgres port; omitted from the DSN when empty (driver default, 5432 for Postgres) |
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 (used for sqlite in tests and for managed-DB URLs) |
REDIS_URL |
unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
OIDC_ISSUER |
http://localhost:8180/tavolo |
OIDC issuer URL |
OIDC_CLIENT_ID |
tavolo |
OIDC client id |
OIDC_CLIENT_SECRET |
unset | OIDC client secret |
OIDC_REDIRECT_URI |
http://localhost:8080/auth/callback |
Login callback URL |
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 |
1000 |
Upper bound on the deadline consumer's poll interval (locally enqueued deadlines fire on time regardless) |
LOGGING_CONFIG |
unset | Path to a YAML logging configuration file (see below). Unset logs DEBUG to the console |
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 and metrics (requires the otel extra, i.e. pip install tavolo-app[otel]) |
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) |
APP_HOST / APP_PORT |
0.0.0.0 / 8080 |
Bind address |
Logging
The application logs through the Python stdlib logging module, one
getLogger(__name__) per module: lifecycle and business events at INFO
(game created/joined, websocket connections, match results, auto-plays),
per-move and store detail at DEBUG.
By default everything at DEBUG level goes to the console. Set
LOGGING_CONFIG to the path of a YAML file to take over the
configuration; the file follows the
logging.config.dictConfig
schema. Keep disable_existing_loggers: false — Granian configures its own
loggers before importing the app, and disabling them would silence the
server. Example for quieter production logs (WARNING for third parties,
INFO for the application):
version: 1
disable_existing_loggers: false
formatters:
default:
format: "{asctime} [{levelname}] ({processName:s}/{threadName:s}) - {name} - {message}"
style: "{"
handlers:
console:
class: logging.StreamHandler
formatter: default
root:
level: WARNING
handlers: [console]
loggers:
tavolo:
level: INFO
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).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 (e.g. 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).
Postgres (statistics, via Tortoise ORM + aerich migrations)
match— one row per finished match: the game played (game_type, one of the ids fromGET /api/game-types, 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. Ratings update in the same
transaction as the match result. To recompute every rating from the
recorded match history (e.g. to backfill matches recorded before ratings
existed):
.venv/bin/python -m tavolo.platform.backfill_elo \
--database-url postgres://tavolo:tavolo@localhost:5432/tavolo
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}}. 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/{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; unauthenticated, unknown-game and non-player
connections are closed with codes 4401, 4404 and 4403 respectively.
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 (see above), 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. - 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
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
.venv/bin/pip install -e ./packages/tavolo-platform -e ./packages/tavolo-scopone -e '.[dev]'
# app integration suite:
.venv/bin/python -m unittest discover -s tests -t .
# per-package suites (each package is self-contained):
.venv/bin/python -m unittest discover -s packages/tavolo-platform/tests
.venv/bin/python -m unittest discover -s packages/tavolo-scopone/tests
# type checks:
.venv/bin/python -m mypy -p tavolo.platform
.venv/bin/python -m mypy -p tavolo.scopone
.venv/bin/python -m mypy --namespace-packages --explicit-package-bases \
src/tavolo/app.py src/tavolo/config.py src/tavolo/static.py \
src/tavolo/aerich_config.py src/tavolo/logging_config.py
Tests run fully in-process: in-memory sqlite for Postgres, in-memory stores
for Redis, a fake OIDC user patched onto the mixins, and httpx /
httpx-ws ASGI transports for HTTP and WebSocket coverage. The platform
suite additionally runs against a DummyEngine toy game, so the
game-independent machinery is covered without importing scopone.
Migrations
The Postgres schema is owned by aerich migrations in migrations/. The
db-migrate compose service runs aerich upgrade before the app starts.
To add a migration after changing
packages/tavolo-platform/src/tavolo/platform/models.py:
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich migrate
DATABASE_URL=postgres://tavolo:tavolo@localhost:5432/tavolo .venv/bin/aerich upgrade
(aerich init-db produces sqlite-flavored DDL when pointed at sqlite;
adjust UUID/TIMESTAMPTZ/BOOL/JSONB for Postgres like the existing
baseline.)
Layout
packages/tavolo-platform/ # game-independent platform (own pyproject + tests)
src/tavolo/platform/
├── __init__.py # public surface (GameEngine, GameSession, Platform, ...)
├── engine.py # the platform<->game contract
├── registry.py # game id -> engine lookup
├── mixin.py # Platform + PlatformMixin (mounts everything on a kaya app)
├── errors.py # shared GameError hierarchy
├── models.py # Match / MatchPlayer / PlayerRating (Postgres)
├── elo.py # chess-style Elo math (1500 start, K=32)
├── stats.py # MatchResult -> Postgres persistence + Elo update
├── backfill_elo.py # recompute all ratings from the match history
├── store.py # Redis / in-memory live-session store (+ deadline queue)
├── deadlines.py # connection-independent timeout scheduler
├── ws.py # WebSocket live-play endpoint (generic envelope)
├── auth.py # auth helpers (HTTP + WebSocket)
├── http.py # JSON request/response helpers
├── pagination.py # keyset (cursor) pagination
├── openapi.py # shared OpenAPI parameter fragments
├── tortoise_mixin.py # TortoiseORM lifecycle (HTTP + WebSocket)
└── routes/
├── health.py # GET /api/health
├── me.py # GET /api/me
├── games.py # lobby: game-types / create / join / snapshot
└── stats.py # match history + leaderboard + Elo ratings
packages/tavolo-scopone/ # scopone scientifico game (own pyproject + tests)
src/tavolo/scopone/
├── state.py # ScoponeState / PlayerState / Card, JSON (de)serialization
├── engine.py # pure scopone scientifico rules
├── errors.py # scopone-specific rule violations
└── plugin.py # ScoponeEngine: the GameEngine adapter
src/tavolo/ # tavolo-app: composition root (this pyproject.toml)
├── app.py # session/OIDC/Tortoise/OpenAPI/Platform/DeadlineScheduler mixins
├── config.py # env -> frozen Settings
├── aerich_config.py # aerich CLI configuration
├── logging_config.py # logging setup
└── static.py # SPA shell hosting
migrations/ # aerich migrations for the platform models
tests/ # app integration suite (platform + scopone wired together)