Repo is now a monorepo:
- server/: the kaya backend, unchanged in behaviour, plus:
- GET /api/me for SPA session detection
- last_move recorded on every play and broadcast in the game state, so
clients can show who played which card the moment they play it
- legal_moves per hand card for the player on turn (rules stay
server-side)
- static catch-all route serving the compiled SPA with index.html
fallback; Tortoise context now bound only for /api/* requests
- configurable OIDC post-login/logout redirects for dev against trunk
- web/: Sycamore 0.9 + WASM frontend (trunk): login via the OIDC flow,
lobby (create match / join by code), live game page over websocket with
card images (CC0 woodcut napoletane deck), capture picker, move banner,
game-over overlay, match history and leaderboard pages
- server/Dockerfile gains a rust+trunk stage building web/dist; the single
app image serves the SPA; compose builds from the repo root with
overridable ports/OIDC env
Verified end-to-end against the compose stack: four OIDC logins, game
creation, three joins by code, websocket play with broadcasts to all
players and out-of-turn rejection. 51 backend tests, mypy and cargo tests
all green.
scopa
A multiplayer backend for scopone scientifico (the four-player, fixed-partnership variant of the classic Italian card game), built on the kaya framework.
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 final score — 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/scopa, 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_URL |
postgres://scopa:scopa@localhost:5432/scopa |
Postgres DSN for match statistics |
REDIS_URL |
unset | Redis DSN for sessions + live games. Unset falls back to in-memory stores |
OIDC_ISSUER |
http://localhost:8180/scopa |
OIDC issuer URL |
OIDC_CLIENT_ID |
scopa |
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 |
APP_HOST / APP_PORT |
0.0.0.0 / 8080 |
Bind address |
Data model
Redis (live games)
scopa:game:<uuid>— the whole match as JSON: players (seat 0/2 = team A, 1/3 = team B), hands, table, captured piles, scope, current turn, dealer, scores, phase (lobby→playing→finished). Sliding TTL (GAME_TTL_SECONDS).scopa:code:<JOINCODE>— the 6-character join code → game id index.scopa:game:<uuid>:lock— a short-lived lock serializing every mutation.scopa:game:<uuid>:events— a pub/sub channel carrying "state changed" signals; every open WebSocket reloads the state and pushes the personalized view to its player.
Postgres (statistics, via Tortoise ORM + aerich migrations)
match— one row per finished match: both teams' final scores, winner, target score, hands played, start/finish timestamps.match_player— one row per participant: the OIDCsub, display name, seat, team and whether they won. Unique per(match, user_sub).
When a match ends, the result is written transactionally to Postgres (once, guarded by a flag on the Redis state); the finished state stays in Redis until its TTL expires so clients can still fetch the final board.
REST API
All endpoints except /api/health, /api/docs and /api/openapi.json
require authentication.
| Method | Path | Description |
|---|---|---|
POST |
/api/games |
Create a lobby game. Optional body {"target_score": 11}. Returns {id, join_code} |
POST |
/api/games/join |
Join with {"code": "ABC123"}. The fourth player triggers the deal |
GET |
/api/games/{id} |
Personalized snapshot (only your own hand is visible) |
GET |
/api/me/matches |
Cursor-paginated match history with final scores (?limit=&cursor=) |
GET |
/api/leaderboard |
Aggregated wins / matches / team points per player |
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:
{"action": "play", "card": "07D", "capture": ["02D", "05C"]}
{"action": "play", "card": "07D"}
{"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.stateasks for a fresh snapshot.
After every accepted move the new state is broadcast to all four players.
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. - The match ends when a team reaches the target score (default 11, configurable per game) 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 '.[dev]'
.venv/bin/python -m unittest discover -s tests -t .
.venv/bin/mypy src
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.
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 src/scopa/models.py:
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .venv/bin/aerich migrate
DATABASE_URL=postgres://scopa:scopa@localhost:5432/scopa .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
Everything lives under server/:
src/scopa/
├── app.py # composition root: session/OIDC/Tortoise/OpenAPI mixins
├── config.py # env -> frozen Settings
├── 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)
├── aerich_config.py # aerich CLI configuration
├── models.py # Match, MatchPlayer (Postgres)
├── stats.py # finished match -> Postgres persistence
├── store.py # Redis / in-memory live-game store
├── ws.py # WebSocket live-play endpoint
├── game/
│ ├── state.py # GameState / PlayerState / Card, JSON (de)serialization
│ ├── engine.py # pure scopone scientifico rules
│ └── errors.py # typed rule violations
└── routes/
├── health.py # GET /api/health
├── games.py # lobby: create / join / snapshot
└── stats.py # match history + leaderboard