Skip to the content
Georgi DimitrovdaTuzzo

PlayBelote v2

A clean-room 3D belote with a tavern cast, a jazz salon and a table that lives in a worker

Role
Product owner, directing agent fleets
Status
Unreleased
Source
Private repository
Stack
TypeScriptVitethree.jsWeb WorkerzodBlenderVitestPlaywrightfast-check
A hand of Belote in the 3D tavern theme: a white-haired man in a flat cap, a woman with red curly hair in an embroidered white blouse and a man in a dark flat cap sit at a red and white checked table, with a trick of cards on the cloth and the player's three remaining cards at the bottom. Interface text is Bulgarian.
Mid-hand in the tavern theme, with the score and contract in the top corners.

In numbers

16/ 16

signature moments fired from real game events in each room, end to end

999/ 1,000

matches won by the baseline bot against a random-legal player

438,430

illegal moves thrown at the engine across 2,000 fuzzed games, 0 exceptions

136

logged design decisions, each with the alternatives it beat

144.1fps

tavern table at a human decision point, up from 37.5

The problem

PlayBelote v1 had grown through nearly a year of changes, and its client broke whenever an event arrived twice or out of order, as the alt-tab bug showed. I wanted a second game that kept the rules of the first and none of its assumptions, started as single-player, and put the cards in rooms worth looking at.

The approach

I wrote a brief with the stack decision, a clean-room rule and acceptance criteria frozen before each phase, then ran orchestrated agents against it. The brief rejected eight stacks in writing before anything was built, among them Colyseus, Phaser, React Three Fiber and Godot. A grader agent that had not written the code ran the commands and reported the results, and each decision went into a log with the alternatives it beat.

The tavern table at the start of play: close views of the three seated characters, a goat and a clay pot behind them, plates of food on the checked cloth and the player's eight cards fanned along the bottom, each court card showing a Bulgarian folk character.
The start of play, closer to the characters.

How it works

  1. Two rooms, one renderer

    The Salon is an art-deco card room after Sofia's old city casino: midnight-green felt with visible fibre, a brass rail, and one warm lamp whose pool of light follows the active player. The Mehana is a tavern table under a grapevine, with a checked cloth, a wood stove and the Rila lakes in the window. Cards are thin two-sided 3D objects that arc, flip and cast shadows under the lamp, and each one chases its target position on a damped spring, so a dealt card settles with a small overshoot. Every shader is written from scratch.

  2. A cast with jobs

    In the tavern a grandmother deals and comments, the grandmothers on the bench watch and scold real blunders that the bot flags for them, the neighbour looks on over the fence, and Bora, a Shar Pei, snores under the table and wakes for big moments. The goat is a pun: the Bulgarian for trump is koz and for goat koza, so on a no-trumps bid the goat bleats and walks off. A contre brings a kuker bursting in with bells, and a capot brings the full wedding band, a horo around the table and Bora howling along with the clarinet. Each room has 16 signature moments. The characters were flat cut-outs first and looked glitchy against the lit table, so mid-build they became Blender models, compressed with KTX2 and meshopt to 44 MB in total.

  3. Music that keeps score

    All music is synthesised in JavaScript by a look-ahead bar scheduler, with no samples, and it doubles as the streak meter: each consecutive win adds a layer. In the Salon an original modal, hard-bop tune adds walking bass, ride and brushes, then piano. In the Mehana an original rachenitsa in 7/8 adds tapan, gaida, clarinet and accordion up to the full band, the score odometer ticks in the same 7/8, and the deciding deal switches to the kopanitsa's 11/16. Character voices are formant synthesis in Bulgarian and English, and an intensity dial (calm, lively, wedding) scales particles, reactions and music.

  4. A table actor in a worker

    All game state and clocks live in a table actor inside a Web Worker. The client talks to it through a zod-validated protocol with sequence numbers, per-seat redacted events, and a snapshot plus missed events on reconnect. A game is its seed plus its action log, so the same table can later sit behind a socket. Hiding the tab and coming back, reloading, or restarting the browser mid-trick all resume to the actor's exact view.

  5. Search bots with an exact endgame

    Bots run determinised search over up to 48 sampled worlds per decision, with common random numbers across candidate moves and heuristic rollouts. Once the deciding seat holds two cards or fewer, minimax solves the hand exactly. I looked at ISMCTS in the stack research, and the determinised search is what the bots use. The baseline bot won 999 of 1,000 matches against a random-legal player, at a mean of 5.58 ms per decision.

  6. Gates, a decision log and a frame budget

    Acceptance criteria were frozen before each phase. All 112 spec ids were covered, a fuzz run threw 2,000 games and 438,430 illegal probes at the engine with 0 exceptions, and 56 layout shots covered 20 viewport sizes from 320 by 480 to 3840 by 2160. At a human decision point the tavern table rose from 37.5 to 144.1 fps, and its desktop download fell from 54.8 MB to 19.4 MB.

A declaration prompt in the tavern theme: a gold Tierce button and a Without declaring button at the right, two cards on the checked cloth and the player's hand fanned along the bottom.
Declaring a tierce, a run of three cards.

What I chose, and what lost

Chose

Single-player first, with a transport that can swap later

Over

Building multiplayer in the first pass

I wanted the single-player game finished before any networking. The table protocol moves from postMessage to a WebSocket later without touching the game.

Chose

Real 3D models

Over

Flat cut-out characters

The cut-outs looked glitchy and out of place against a lit 3D table.

Chose

Compressed models built from untouched Blender originals

Over

Compressing the shipped files in place, or Draco geometry compression

Meshopt decodes faster and three.js ships its decoder inline. The originals stay byte for byte, so a check can re-encode every model and confirm the shipped files match: 36 the same, 0 different.

Chose

A clean-room build from the written rules

Over

Porting the v1 code

Reusing v1 would carry its old assumptions into the second game.

Outcome

Phases one to three passed the gate in round three, with 717 tests across 63 files, lint clean and type checks passing in 8 of 8 projects. The no-lag performance gate sits on a separate branch and passed 6 of 9 criteria: the salon table still shows a 69.3 ms worst frame against a 50 ms budget, and the 6x CPU phone proxy fails. No real phone was tested; phone play is emulated. The game has no remote and no public build, and at handoff I had not yet played the finished version.

What comes next

Record a play-through or host a build, fix the two failing performance criteria, then add the WebSocket transport the protocol was designed for.