Truco is a game of hidden information and bluffing. In a real-money online game, that becomes an engineering requirement: the client must not know anything the player would not see at the table. At PixTruco I wrote the game server, the rules engine, the truco wallet and the game app that runs inside the site. Here are the main decisions.
The server decides, the client only sees
The client never gets the raw match state. The server projects one view per player: their own hand, the other players' card counts and the actions allowed at that moment, such as playing, calling truco, answering or deciding the hand of eleven. A card played face down arrives masked for everyone, even on the trick result screen. Even the prize on screen comes from the settlement done on the server, not from a calculation in the client.
The exceptions are the ones the rules themselves allow: in the hand of eleven in doubles, whoever decides sees their partner's hand, and in Mineiro's blind iron hand even your own hand is face down.
sequenceDiagram
participant A as Player A
participant B as Player B
participant S as Truco server
participant DB as PostgreSQL
participant E as Spectators
A->>S: play-card
S->>S: engine checks turn, card and phase
S->>DB: versioned state snapshot
S-->>A: game:state with their own hand
S-->>B: game:state with only the card count of A
S-->>E: neutral state, 3 s later
The rules engine
The engine is plain TypeScript, with no framework. Every public action, such as playing a card, calling truco, answering or giving up, validates everything before changing anything and returns success or the reason for the error. A bug I fixed shows why that matters: an invalid play cleared the "no calling truco twice in a row" lock before being rejected, so a series of invalid plays could escalate the bet.
Variants plug in as rules. In Paulista the manilha comes from the turned card and bets go from 1 to 12; in Mineiro the manilhas are fixed, bets are 2, 4, 6, 10 and 12 and there is the blind iron hand. A new variant is a new object, without touching the match flow. The deck can be clean or dirty, and another fixed bug computed the manilha with the clean deck order on the dirty deck too.
Decision time belongs to the server: 15 seconds to play and 30 for truco or the hand of eleven. The clock is tied to a key of the table situation, so reconnecting or receiving the same state again does not reset the deadline. Since it is part of the snapshot, not even a restart resets it.
The realtime protocol
I use Socket.IO over websocket only, on two channels: one for the lobby, rooms and invites, and another for the match, spectators and tournaments. Every state carries the server time, and the client computes its local deadline from it, without relying on the device clock. After each trick the server holds the result for 2 seconds before releasing the next playable state, so both sides start counting from the same message.
Spectators sit in a separate room and get a neutral view with no hands, captured at publication time and delivered 3 seconds later. Signals and emotes come from fixed lists; a team signal never becomes public and spectators never receive them.
Drops, tabs and phones
- Multiple tabs: one active socket per player in a match; the previous tab is told it was replaced and commands from old sockets are rejected.
- Disconnection: the opponent is told and the player has 45 seconds to come back before forfeiting; matches against the bot are never abandoned.
- Phone in the background: on return, the app asks for a new token and explicitly rejoins the match, with infinite reconnection capped at 10 seconds; a token that arrives late never opens the wrong match.
- Timers: every timer callback is guarded so an error cannot take the process down, after an incident where a rematch timer did exactly that.
- Absence: if a player disappears, the server plays for them deterministically and conservatively: the leftmost card, folding to truco and never calling it, because you do not automate betting with the money of someone who is away.
Every state change writes a versioned snapshot, with optimistic concurrency so a late write never rolls the state back. A match in progress is restored without redealing, and at the end the event buffer becomes the replay.
Race-free matchmaking
The queue is persisted in the database, with a partial unique index that keeps the same player from waiting twice. Forming a match happens in a transaction with an advisory lock per room: read who is waiting, choose and mark them as matched. In doubles, selection prefers complete partner groups.
Money charged and paid once
- Buy-in: only debited once everyone is connected, in the same transaction that starts the match, with the account locked and a fixed order across players to avoid deadlocks.
- Incident: in production, one match produced two debits per player because a finished match was reopened; today finished or voided matches return their result before any engine is created.
- Settlement: the prize deducts the rake, and the last guard is an UPDATE conditioned on the playing status; if nothing is updated the transaction rolls back, which prevents paying twice even across replicas.
- Voiding: first claims the voided status and only then refunds, so it never races the settlement.
- Reconciliation: every 10 minutes, matches stuck for more than 2 hours with no live state are voided with a refund.
Opening the match safely
The site opens the match in an embedded game app. Launching creates a short-lived game token, separate from the access token and tied to the central session: revoking the session drops the socket. The bridge between the site and the iframe accepts only the exact origin, checks who sent the message, validates the payload with Zod and never posts to any origin. The game only reports events; the site decides about wallet and queue.
Tests
The engine tests build exact states and rehydrate them, so they are deterministic without depending on the shuffle, which uses Fisher-Yates with crypto.randomInt. They cover manilhas, tie-breaks, truco escalation, the hands of eleven and iron and information isolation. Others cover the deadline across reconnection, the spectator delay, stale sockets, a buy-in charged once even with concurrent joins and a rematch that cannot take the process down. In the game app there are tests for a late token, returning from the background and the iframe bridge.
What is still missing
- Today the server runs as a single instance: live state is in memory. The Socket.IO Redis adapter and Redis snapshots, to scale horizontally, are planned.
- An auditable shuffle seed, commit-reveal style, is still a plan.
- The bot only knows the Paulista heuristic so far.
