← All work A-06 Case · /en/dev/pulya

Pulya

Private online preferans for a circle of friends: three or four players at one virtual table, each seeing the others on video at their seat. No accounts — one person creates a table and gets a four-digit number, the rest join by number and password. The server checks every rule and each player sees only what they are entitled to see.

Status
Done — we have played it
Role
Everything: rules engine, server, front end, video, deploy
Stack
Python 3.13FastAPIWebSocketSQLiteReact 18TypeScriptViteWebRTCcoturnnginx streamCaddyDocker ComposeGitHub Actions
W 1160
A-061600 × 1000
pulya
Pulya: an online preferans table with video tiles and bidding panel
desktop only
01What it is 3–4 players · desktop only3–4 players

The server checks every rule

All the rules — bidding, the talon, whist, play, raspasy, scoring the pulya in three conventions — are checked by the server.

Video and sound go directly between browsers (WebRTC, mesh of up to four), the server only relays the signalling. Interface in Russian, desktop only, with a 'Chokn'sya!' button for a toast over the cameras.

02Services 4 containers · 1 VPS

How it is built

VPS · docker compose
Player's browser · React SPA, WebRTC + RNNoise
mux · nginx stream, TCP 443 by SNI
web · Caddy: HTTPS, static, proxy
server · FastAPI: hub, engine
Other players' browsers · P2P video
coturn · STUN/TURN
SQLite · table journals, history
Public STUN
Let's Encrypt
GHCR
GitHub Actions
443 WebRTC mesh TURN over TLS certificates
Legend
  • HTTP
  • external
  • requests
  • store
Player's browser · React SPA, WebRTC + RNNoise
Other players' browsers · P2P video
Public STUN
mux · nginx stream, TCP 443 by SNI
coturn · STUN/TURN
web · Caddy: HTTPS, static, proxy
server · FastAPI: hub, engine
SQLite · table journals, history
Let's Encrypt
GHCR
GitHub Actions
VPS · docker compose
Legend
  • HTTP
  • external
  • requests
  • store
Four containers on one small VPS; the images are built in CI because the server has little memory.
03Numbers snapshot · 2026-10-27

In numbers

  • 872 server test runs (220 functions, 95% coverage)
  • 223 front-end unit tests
  • 17 browser e2e scenarios
  • 24 commits in 8 days

Also: 21 kinds of game actions · 3 scoring conventions · 19 deploys, all green.

04Engineering decisions engine · network · client

What was hard

Decision 01

The table is a journal; state is rebuilt by replay

The rules engine is pure, deterministic Python — no network, time or randomness; the deck is shuffled by the table layer from a seed. So the database stores not the game state but the seed of every deal and the players' actions, and after a restart — there is one on every deploy — the table is restored by replaying the journal through the engine; the game is not lost. When the rules changed, old journals could become invalid; the replay survives it: an obsolete accepted action closes the deal by the saved layout, dangling proposals are skipped, and on any other invalid action the deal becomes current and later ones are dropped.

Decision 02

Card visibility is a right in the engine, not a "your cards are visible" rule

In preferans cards open as the deal goes, and not for everyone alike. Every hand records who sees it and who plays it, and the state leaves the server only through one presentation function for a specific player. Tests play hundreds of random deals in all conventions, three- and four-handed, and check on every step that nobody's closed cards leaked.

Decision 03

TURN over TLS on the same 443 as the site

For networks where only 443 is open, nginx in stream mode sits in front of Caddy: it reads the domain from the start of the TLS handshake and, without decrypting, sends turn.* to coturn and everything else to Caddy. Caddy obtains the certificate for the TURN domain too; TURN passwords are issued by the game server — temporary, HMAC-signed from a shared secret — so coturn and the server never talk to each other over the network.

Decision 04

Video and sound

WebRTC mesh with "perfect negotiation" for simultaneous offers; outgoing video is capped (~640×360, 24 fps, 600 kbit/s) so four streams survive a weak uplink; the microphone passes through RNNoise (a neural noise suppressor in WebAssembly inside an AudioWorklet) and the cleaned track replaces the raw one in live connections without renegotiation.

Decision 05

Animation and sounds come from the difference of two snapshots

The server sends state snapshots and knows nothing about animation. The client diffs two neighbouring snapshots with pure functions and plans which cards fly where and what sounds when; the table always draws the latest snapshot, so you can move at once, and whatever is 'already gone' is drawn by copies in a separate layer on the Web Animations API — after three seconds the screen is guaranteed to equal the last snapshot. Table sounds were generated with ElevenLabs sound effects and normalised with ffmpeg; the 32 cards are WebP.

05Timeline 18.10 → 25.10.2026

Eight days

  1. 18.10.2026Start: design system and mockups («smoky club», exported from Claude Design), the rules document, principles for the coding agent; the rules clarified in two rounds of questions and answers; the main commit — engine with tests, server, front end, video, deploy; first production deploy the same day
  2. 19.10Card dragging, WebP cards, sounds, RNNoise, the three-player score sheet, the four-player table layout
  3. 22.10Card animation: throw, talon and discard, trick to the winner, dealing from the dealer's deck
  4. 25.10Rule change in the engine (talon in two moves) with support for old journals; last trick in raspasy
06Screens desktop only · 1600 × 1000desktop only
07Integrations 7

What it talks to

  • WebRTC
  • coturn
  • RNNoise
  • Let's Encrypt
  • ElevenLabs (sound effects)
  • GitHub Actions
  • GHCR