Baza: A Spanish Card Game With Its Rules Intact, in Half a Megabyte
I rebuilt La Cuatrola for YouTube Playables: one legality function for humans and bots, art and sound generated by code, six languages, and a Daily Hand the engine solves exactly. Here's what the table taught me that no rulebook could.
There is a card game from my part of Spain that most people learn at a kitchen table and half-forget. Twenty cards, four players, five tricks. One rule shapes every hand: if you can beat the trick, you must. And there is no written canon for it. Fournier’s catalogue of thirty-five games has Tute and Guiñote but not Cuatrola; Wikipedia has no article; the closest thing to official rules are municipal tournament bases that contradict each other from one town to the next.
I wanted a version that keeps the rules intact and removes the homework. I also wanted it to fit YouTube Playables, which means a tiny download, a game that boots inside a hidden WebView on any phone, and no network calls of any kind. One developer, every discipline.
Baza went live on 23 August, sixteen days after the first commit, and I kept polishing it against playtesters for two more weeks. It is also on itch.io. This post is about the parts that were interesting to build: the rules engine, the solved Daily Hand, the art that ships as code, and the rules I got wrong until real players corrected me.

The constraints came first
YouTube Playables is a strict host. index.html must be at the zip root and its SDK script must be the first thing loaded. You call firstFrameReady() when a loading screen is visible and gameReady() only when the menu is interactive; calling it early fails certification. Every file should be under 512 KiB, the initial bundle should be small, and any fetch to a non-bundle URL, including a font or an analytics ping, is a rejection. The game is first loaded in a WebView with a viewport of 0×0, so the layout has to survive that too.
I wrote those constraints down before writing any code, and they decided the architecture. No fonts from a CDN meant bundling a subset of Alegreya. No remote assets meant asking what assets I needed at all. The answer turned out to be almost none.
The hosted bundle today is 1.7 MiB raw and 0.60 MiB gzipped. Phaser itself is 355 KB of that; my code is 52 KB. The share image and the touch icon are never fetched during play, so about 0.45 MiB reaches a player before the menu appears.
One function decides legality
The engine is pure TypeScript with zero Phaser imports, tested in Node. The rule that makes the game is about fifty lines:
export function legal(hand: readonly Card[], trickCards: readonly Card[], trump: Suit): LegalResult {
if (trickCards.length === 0) return { cards: [...hand], reason: 'FREE' };
const lead = trickCards[0].suit;
const winner = trickCards[winningIndex(trickCards, trump)];
const inLead = hand.filter((c) => c.suit === lead);
if (inLead.length > 0) {
if (winner.suit === lead) {
const beat = inLead.filter((c) => c.strength > winner.strength);
if (beat.length > 0) return { cards: beat, reason: 'MUST_BEAT' };
}
return { cards: inLead, reason: 'MUST_FOLLOW' };
}
const trumps = hand.filter((c) => c.suit === trump);
if (trumps.length > 0) {
if (winner.suit === trump) {
const over = trumps.filter((c) => c.strength > winner.strength);
if (over.length > 0) return { cards: over, reason: 'MUST_OVERTRUMP' };
return { cards: [...hand], reason: 'FREE' };
}
return { cards: trumps, reason: 'MUST_TRUMP' };
}
return { cards: [...hand], reason: 'FREE' };
}
That function is the whole design philosophy. It returns the legal set and a reason code. The table uses the set to dim the cards you may not play and the code to print one line: “You must beat the 3★ of Cups if you can.” The three bots choose only from the same set. There is no second place in the codebase where legality is decided, and a fuzz test plays 10,000 random hands asserting that every bot move is in it.
The rest of the engine follows the same shape: trick resolution, marriages, bidding with its target restriction, scoring with the tie rule, a seeded RNG so every deal and every bot move is reproducible. The normative rules live in a spec document, every decision marked [decision] there has a test, and the suite is at 303 tests. The Phaser scenes render engine state and forward taps. They never compute a rule.
A deck that teaches itself
The biggest barrier for someone who has never seen a Spanish deck is that a Three beats a King and is worth ten points. So every card prints its own strength as a starred numeral and its point value in the corner. Only the two courts carry figures, the King and the Queen, because they are the marriage pair and the picture explains the rule. Suits differ by shape as well as colour.

The first hand is a real hand with nine scripted coaching lines, one at a time, never a modal. The deal is seeded so every line fires, and a test locks that seed. After it, coaching turns off and the reason line on dimmed cards stays forever. Declarations unlock by play, so the bidding round is never the first thing a new player meets.
The review panel took a design argument with myself. Traditional rules say you may only look at the last trick. But the bots track which seat played which card in which trick to compute voids, and that is knowledge the table’s own players already have for free. The panel grants the human exactly that and nothing more: the five tricks in play order with a crown on each winner, and the cards still unseen. Never where those cards are.
Art and sound are code
The build has no images and no audio files. Twenty card faces, the backs, the emblems, the felt, the vignette and the seat medallions are drawn on a canvas at boot. The eleven sound effects are WebAudio oscillators with short recipes:
marriage: [
{ f: 523, at: 0, len: 0.12, type: 'sine', g: 0.13 },
{ f: 659, at: 0.1, len: 0.12, type: 'sine', g: 0.13 },
{ f: 784, at: 0.2, len: 0.2, type: 'sine', g: 0.13 },
],
This started as a size decision and became a rule for everything around the game. The itch.io cover, the logo, the social preview card and the trailer end cards are all drawn by the same texture code on a dev-only page, at a higher supersample. When I fixed the Queen’s tiara, every piece of marketing art updated with it.
The trailer’s audio is the game’s own: I patched AudioNode.connect at boot to tee everything reaching the destination into a MediaStreamDestination, and started a MediaRecorder on the frame the video cut begins. No library music. The quiet clicks of the table are the game’s identity.
The table outranks the book
Two rules changed after launch, and both changed because players told me they were wrong.
The first was what happens when someone has trumped and you cannot beat that trump. My original rule forced you to play a lower trump anyway, “for consistency with Tute”. Playtesters objected every time it happened. I went to the sources: five of five Cuatrola references say you may play any card and keep your trumps, and even Ludoteka’s own Tute page has the same conditional obligation. The stricter rule appeared nowhere. I reversed it, rewrote the spec with the citations, and then had to fix the bots’ void inference, which would have been silently wrong under the new rule: a non-trump discard only proves you are out of trumps if no trump had been played at that moment.
The second was whether a team may declare two marriages on the same trick. Ludoteka says yes; Fournier’s Tute rules say only one at a time. I asked the family WhatsApp group. They play one per won trick, the 40 first and the 20 at the next trick you win, even when the two pairs belong to different players. That is what ships. It is also the more interesting rule: the defence can kill the second marriage by taking the tricks in between.
A third rule I measured and rejected. Some tournament bases let a player throw in a hand worth 15 card points or fewer. Over 50,000 simulated hands, a hand that weak appears in 3.2% of deals, and its team still wins 38.7% of those hands. It is a mercy rule for the pace of physical shuffling, not a balance rule, and it would delete exactly the hands where defensive skill shows.
The Daily Hand is solved, not scored
The first version of the Daily Hand scored your team’s card points. A playtester pointed out that this ignored Solo, Cuatrola and Quintola entirely, and that some days you were going to lose whatever you did. Both true, and the second one is measurable.
This game is small enough to brute-force. Five tricks, and legal() is severe enough that at most turns you have one or two legal cards. The Daily’s bots carry no randomness, so a line through the hand is determined entirely by the human’s choices. Over a year of Daily deals the tree has a median of 38 complete lines and a worst case of 480, enumerated in under 20 ms. Nothing is estimated.
So the Daily now scores you 0 to 100 on how well the hand was played, as your percentile among every line it could have taken, with the best line pinned to 100. Lines rank on match-point swing first, so a made Cuatrola counts four times a quiet win and a failed Quintola costs five, and on card margin second.
The finding that vindicated the complaint: on 39% of days, the best possible outcome is still a loss. Those days now score 100 when you lose as well as anyone could. And because the tree is fully enumerated, the post-mortem can name the exact decision: “Trick 3: the 4★ of Swords was worth 11 more points.” Not “first divergence from the best line”, which would be wrong when lines tie, but the first decision where the ceiling dropped.
Measure the thing you are sure about
The menu said a short match took five to eight minutes and a classic one ten to fifteen. I had never checked. When I did, with 2,000 simulated matches per target, a match to 11 takes a median of 16 hands and a match to 21 a median of 32. About fifteen and thirty minutes. Wrong by three to four times, because most hands are worth one point and alternate between the teams.
That number killed a feature. The classic match was locked behind finishing a short one, which meant asking for a quarter of an hour to unlock half an hour. Nobody reached it, me included, after weeks of play. The lock is gone and the buttons carry their real durations.
I also tried a shorter 7-point target and the data killed that too. The bidding is tied to the target: you may not declare a contract whose value would take you past it, so at 7 a Quintola is legal only at 0-0. Eleven is about the floor where declaring still breathes.
One game, three boards
Playables requires landscape to be playable. A pillarboxed portrait column on a laptop drew cards at 75 CSS pixels with two thirds of the screen bare. So there are two boards: the 720×1280 portrait table, and a 1280×800 wide board drawn with the camera zoomed 1.6× to fit. The partner’s fan moves beside the trick instead of above it, the bidding dock sits over the empty trick area, and rotation is a snapshot and a scene restart through the same path a saved match already resumes by. It waits for a quiet moment, so a card in flight always lands first.

Tall phones got the mirror of that lever. A 20:9 phone was drawing the 720-wide world at scale 1 with 30% of the screen as felt and seat tags at 8 CSS pixels. Now the portrait board narrows to the window’s own shape, floored at 600, and the camera draws it 1.2×. Every x in the layout was already the spine, an edge margin or an offset from one, so nothing changed at 16:9, and a test pins that to the pixel.
The other Phaser lesson worth passing on: scene.start(key) with no data keeps the previous visit’s data. My Settings scene was reached from the pause menu with {returnTo: 'pause'} and from the main menu with nothing, and the second call inherited the first’s flag. Back from Settings went to a pause overlay over a stopped table, and Continue resumed a scene that had already shut down, which Phaser marks active without ever running create(). A green screen with no button anywhere, four taps from the menu. Always pass data, even {}.
Six languages, six vocabularies
English, Spanish, French, German, Portuguese and Italian ship in the same build. The rule that makes them good: each of those languages has a living trick-taking tradition, so the game borrows that tradition’s own words instead of translating the English. A trick is a pli in French from Belote, a Stich in German from Skat, a vaza in Portuguese from Sueca, a presa in Italian from Briscola. Two happy accidents: Schnapsen already scores a King-Queen pair as a Zwanziger (20) and a trump pair as a Vierziger (40), which are this game’s marriage values exactly, and Belote’s dix de der is already the name for ten points on the last trick.
Punctuation is part of translation. Seventeen em dashes had no business in the non-English tables: French, German, Italian and Portuguese use a spaced en dash for that break, and Spanish does not use a free-standing dash for it at all. A test now enforces it, because it is invisible to an English reader and obvious to everyone else.
Turkish is a live Playables market and is not shipped, because the bundled font subset lacks ş, ğ and İ. That is font work, not translation work, and I would rather ship six good tables than seven with Arial fallbacks mid-word.
Where it stands
The game is complete: 303 tests, a browser harness that plays the real minified bundle end to end with mouse and touch on all three boards, and a pass on Google’s Playables Test Suite. It is live at baza.blugart.dev on Cloudflare Pages and on itch.io. The Playables portal is invitation-only; the interest form went in on 23 August and silence is the default outcome, so I keep building rather than waiting.
CrazyGames rejected it with their catch-all line about overall quality. Their guidelines weight illustrated art, music and a loop readable in ten seconds; Baza is procedural art, no music and a rules-teaching first hand. I removed their SDK the same day rather than reshape the game around a platform that objects to its identity. A Coolmath Games submission is prepared, since their brief describes a calm thinking game and they pay a hosting licence.
Much of the typing was done with Claude Code. Every rule was decided at the table.
What I learned
Write the constraints before the code. Playables’ rules decided the architecture, and the size budget turned into the art direction.
One legality function, for everyone. The human’s dimmed cards and the bots’ choices come from the same fifty lines. Bugs there are found by fuzz, not by players.
The living table outranks the written source. The two rules I got wrong were both “for consistency” choices no source supported. Ask the people who play.
Measure the thing you are sure about. Match length, unwinnable Daily days, the hopeless-hand rule: every number I finally checked changed a decision.
A clean report from a check that has never caught anything is not evidence. My first text-overlap sweep found nothing because Phaser Graphics report no bounds. My first deployment prune reported deleting 23 builds and deleted none. Prove the detector fails before trusting that it passes.
Play a hand at baza.blugart.dev. It takes a minute.