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

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.
How it is built
- HTTP
- external
- requests
- store
- HTTP
- external
- requests
- store
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.
What was hard
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.
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.
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.
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.
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.
Eight days
- 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
- 19.10Card dragging, WebP cards, sounds, RNNoise, the three-player score sheet, the four-player table layout
- 22.10Card animation: throw, talon and discard, trick to the winner, dealing from the dealer's deck
- 25.10Rule change in the engine (talon in two moves) with support for old journals; last trick in raspasy
How it looks
-
[TBD: Playing a deal] 1600 × 1000
-
[TBD: The score sheet at the end of a pulya] 1600 × 1000
What it talks to
- WebRTC
- coturn
- RNNoise
- Let's Encrypt
- ElevenLabs (sound effects)
- GitHub Actions
- GHCR