The World at PlayWorld Games · Ready to Play

AI engineering note · 01

Card-game AI must first learn not to see: information sets, partnerships, and Wasm

Board games put depth, branching, and evaluation in plain view. Card games begin with a different question: what is the AI allowed to know? The location of a hidden hand, the information carried by a pass, and what a partner can infer from public play all change the tree. We began with three trick-taking games, rebuilt the entire card engine for Dou Dizhu, and then brought melding, climbing, bidding, and attack-and-defense games onto it. This is the part of that work that was hardest—and easiest to misunderstand.

  • Card-game AI
  • Information-set search
  • On-device computing

Rule one: the AI cannot see a card it should not see

The most dangerous card-AI bug is often not an illegal move. It is a move that is implausibly good. The complete deal necessarily exists somewhere in browser memory: the controller must know where every card lives, animation must deliver cards from the deck to each seat, and settlement eventually needs the truth. If search receives that state, it does not have to cheat deliberately. One convenient field or debug serialization is enough to expose every hand.

We therefore separate truth from observation. The controller alone owns the complete zone ledger. UI, Workers, hints, and AI receive only the acting seat's observation: its own hand, public cards, remaining hand sizes, public actions, and constraints that the rules permit it to infer. Moving an unrevealed card to another compatible hidden location must leave the AI request unchanged. In Hearts, the other three hands never enter the Worker. In Dou Dizhu, the two farmers cannot treat one another's concealed hands as a private team channel.

That boundary continues through the DOM, accessibility text, animation keys, logs, and search progress. Rendering a card back while writing `data-card=AS` or an ace-of-spades aria label is still a leak. ‘Only observation may enter UI and AI’ eventually became a hard Card Engine contract instead of a convention every game author had to remember.

Hidden information is not an algorithm option. It is a trust boundary that runs from rules truth through rendering and into search input.

Three trick-taking games forced us to build a real card engine

The first card-table framework served Hearts, Spades, and Euchre well. It also carried quiet assumptions: four seats forever, one action meant one played card, the center always contained a trick, payoff was always a four-number tuple, and a deck fit into fixed suit bitsets. Those shortcuts look reasonable as long as every new game is another four-player trick-taker.

Dou Dizhu broke six boundaries at once: three players, 54 cards and two jokers, a 20-card landlord hand, bidding and doubling, actions containing anywhere from one to twenty cards, and combinations rather than tricks in the center. Instead of adding a growing `if (gameId)` chain, we built Card Engine V2. Physical card instances were separated from rule faces; one zone ledger took responsibility for conservation; seats and payoffs became dynamic vectors; each game owned a structured action union; and trick, combo, meld, or combat center stages became plugins.

That instance/face split matters in double-deck games. Two eights of hearts can carry different CardIds for conservation, animation, replay, and serialization while Guandan still counts them as the same rank. Repeated Pinochle faces no longer have to pretend they came from a standard single deck. Rules operate on faces and multiplicity; physical identity remains available where the product actually needs it.

The rewrite was not approved by saying the games felt unchanged. Before migration we froze 300 initial states, 300 complete hands, and 13,370 pre-action checkpoints. Every checkpoint recorded phase, actor, legal action set, public semantics, and settlement. V2 replayed them exactly before the old engine, Worker protocol, and Wasm binaries left production. We preserved reproducible behavior evidence, not a compatibility path nobody would ever dare delete.

Determinization is not merely shuffling the cards left over

Search still needs futures even when the AI cannot see hidden cards. Determinization samples complete worlds compatible with the current observation and compares actions inside them. The work is hidden in ‘compatible.’ A player known to be void in Call Break cannot receive that suit. An upcard already taken into the maker's Euchre hand cannot reappear in the kitty. An unseen red three may be constrained to the stock during a Canasta phase. Double decks must satisfy the physical capacity of every repeated face.

We also cannot give every rank-count arrangement equal probability. One arrangement may represent hundreds of concrete deals while another represents only a few. Sampling arrangements uniformly biases the marginal probability that an individual card belongs to a seat. Our determinizers weight the conditional distribution of physical deals and test marginal and joint frequencies on small states whose probabilities can be calculated by hand. Conservation proves that no card was invented; it does not prove that the distribution is right.

When roots are compared, we try to give them the same hidden worlds—common random numbers—so a fortunate sample for action A is not mistaken for strategy. This exposed a very concrete Pinochle bug: an early complete-Wasm kernel restored state at the start of each world, but not before each root. One rollout consumed cards that the next root then inherited. Totals could still look plausible while the ranking was already corrupt. A root must now produce the same statistics alone, in the full table, after reordering, and after another legal root is inserted before it.

There is a subtler theoretical trap as well. Solving every true world with perfect information and voting over the answers creates strategy fusion: mutually incompatible world-specific plans are combined into a decision no player could execute from one information set. Tree identity, choices, and rollout policy have to respect what the acting player could observe at that point.

No one search algorithm owns the card table

Hearts, Spades, and Euchre fit Action-Observation ISMCTS: the public tree advances through actions and observations, each iteration begins in a compatible world, and utility returns to information-set nodes. Dou Dizhu has far wider actions, while suits are often irrelevant to the strategic identity of a combination. Its browser path uses root-UCB information-set Monte Carlo, folds equivalent roots by rank multiset, and hands small endings to an exact decomposition DP.

Melding games mix search with exact optimization. Gin Rummy must find non-overlapping sets and runs, then account for knock, layoff, gin, and undercut; memoized DP is the natural fit for hand decomposition, while search decides draw source, discard, and timing. Canasta compares the stock with taking the entire discard pile across hidden worlds, but exact DP remains better for meld and wild-card allocation. Replacing these small solvers merely to put one MCTS label on everything would make the engine less reliable, not more unified.

Guandan enters paired-root search only below approved endgame thresholds; its opening is too wide for the same treatment. A Durak decision can pass through attack, defense, throw-in, taking, replenishment, and seat exit, so rollout must understand a whole bout. Games with a stock also have genuine chance nodes: the future deck cannot be fixed to the one sequence known by the controller and treated as if the searcher knew it.

We eventually gave root actions a simple rule: each must be one complete legal action from the controller's point of view. If Gin Rummy strategy compares taking an upcard together with the required discard, TypeScript cannot execute half while Wasm handles the rest. Bidding, passing, and multi-card combinations cannot switch actors halfway through search merely to make an ABI smaller.

Partnership is easiest to implement as another kind of cheating

A partnership game cannot simply add individual scores. Dou Dizhu's farmers share a win condition; Guandan and Spades partners also act toward one settlement. But a shared objective is not a shared hand. At each turn, a seat reasons again from its own observation. Its partner's real cards, tree, private evaluation, and random seed cannot travel through a convenient ‘team message.’ Cooperation comes from team payoff, public history, remaining counts, and control of play.

Yielding also needs threat gating. A partner with one card left does not automatically justify a pass. If the landlord has two cards and holds control, or the main opponent can finish before the partner receives the lead, the AI has to block first. Our Dou Dizhu fixtures contain both safe-yield cases and mandatory-interception counterexamples. Replacing a partner's unrevealed hand with another compatible hand must still leave the acting player's input indistinguishable.

Card AI is not only play policy. A Spades nil bid, an Euchre lone hand, or Pinochle bidding and trump selection changes the utility of the whole deal before the first card is led. Calibrated heuristics may handle bidding, information-set search may handle play, and exact enumeration may handle meld. One enormous tree from auction to final trick is usually slower, harder to test, and no more principled.

In 200 paired Dou Dizhu games using the same deals with landlord and farmer roles swapped, the upgraded AI won 124. The more revealing number is the farmer result: 68 wins out of 100 against an old baseline of 49. That is evidence that team utility, controlled yielding, and landlord-threat gates did not collapse into three agents racing only for themselves. It remains a frozen proxy baseline, not a claim about human or tournament strength.

Wasm pays when the whole hot loop crosses the boundary

Card search has a fragmented hot path: encode observation, determinize, generate legal actions, advance state, roll out, settle, and back up utility. If the tree remains in TypeScript and calls Wasm for a tiny hand score, boundary traffic and object construction can consume the gain. ‘Complete Wasm’ has a strict meaning here: within an approved phase, hidden-world sampling, successors, state transition, search, rollout, and terminal utility all stay inside one module call. TypeScript retains untrusted input handling, the authoritative root catalog, cancellation, and final legality checks.

The first three trick-takers gave us unusually clean equal-work results. Hearts was 8.94–9.23 times faster at fixed simulations, Spades 6.51 times, and Euchre 8.00 times, with root visits, accumulated utility, and selected action aligning exactly. Later games use different topologies. Canasta's production path fell from 45.87 ms to 0.70 ms, Guandan from 1,407.34 ms to 103.05 ms, and Pinochle from 1,630.15 ms to 34.72 ms—65.69, 13.66, and 46.95 times respectively. We no longer pretend their raw root statistics mean the same thing; rule semantics, original-action legality, regret under the old oracle, and seat-swapped games are reported separately.

There are counterexamples. Indian Rummy's complete draw decision improved from 9.944 ms to 1.483 ms, but its discard-only phase took 0.610 ms in Wasm versus 0.143 ms in TypeScript: just 0.235 times the speed. The absolute cost remains below a millisecond, so the product keeps one coherent module, but the boundary loss stays visible in the report. ‘Uses Wasm’ is not a result; the profile of a complete decision is.

Dou Dizhu's figures are larger and need even more care. An opening with 113 roots moved from 96 TypeScript simulations in 512.3 ms to 256 Wasm simulations in 16.3 ms, about 83.8 times normalized throughput. A seven-root middle game reached about 261.7 times. The workloads differ, so these are not raw equal-work latency ratios and certainly not playing-strength multipliers. Role-swapped complete games and adjacent-difficulty matches remain separate gates.

  • ‘Complete’ always names a phase: complete play does not imply bidding, passing, and melding share that kernel.
  • Fixed equal work measures kernel speed; production settings measure player delay and work completed.
  • Different topologies may choose differently, but observation, rules, team utility, and final legality may not diverge.
  • If the boundary costs more than a tiny phase computes, keep TypeScript or disclose the cost instead of hiding it in an average.

A browser AI must behave like a product, not a benchmark

All search still happens in the player's browser. A page downloads the current game's Worker and content-addressed Wasm on demand, then reuses the compiled module and healthy instance. Hands, sampled worlds, trees, evaluations, and game records do not need to return to a server. The server delivers the program; the device calculates the move.

Local execution also means search must stop cleanly. Restarting, changing rules, or leaving the page must prevent an old generation from landing in a new deal. Synchronous Wasm cannot receive a cancel message mid-call, so deadline or abort terminates and rebuilds its Worker. A forced move returns the controller's original action without starting a Worker at all. One action has one total deadline; a Wasm timeout does not grant TypeScript a fresh full budget.

The production chain is fixed: complete Wasm inside an isolated Worker, observation-only full TypeScript search, a difficulty-matched search-free policy, and only then the controller's first legal action. Download, compile, ABI, or self-test failures disable the broken capability for the session instead of fetching it every move. A normal timeout or search error degrades one request and may retry later. If Workers are unavailable entirely, the main thread runs only a small heuristic rather than freezing the table with full search.

Difficulty follows the same fairness rule. Beginner receives the same deal and action space and makes seeded, natural mistakes among reasonable candidates; it does not alter the cards or throw away a unique immediate win. In frozen Dou Dizhu proxy matches, Casual beat Beginner 130/200, Hard beat Casual 115/200, and Expert beat Hard 110/200. The direction is right and the gap narrows. That verifies a product ladder, not a professional-player rating.

In the end, trust comes from an evidence chain, not one win rate

A hidden-information engine can hide a serious error behind an attractive win rate. It may have seen concealed cards, sampled different worlds per root, gained one extra visit because of ordering, or generated illegal rollout actions inside Wasm. We therefore separate the evidence: legal sets and public transitions; conservation and sampling marginals; isolated, full-table, and reordered roots; fixed-observation reproducibility; memory across one hundred searches; and only then complete games with deals, seats, and teams swapped.

When TypeScript and Wasm share a topology, every root visit and value sum can be compared exactly. When they do not, the honest result is semantic parity plus non-regression. Pinochle selected the same top action in only one of eight frozen observations, yet rule parity, eight of eight original legal objects, regret under the old evaluator, four of four non-regressing team swaps, and a cumulative +33 were each recorded. Compressing all of that into ‘the engines agree’ would erase the information that matters.

After this work, our order of judgment for card AI became simpler: decide what the engine may know; sample what it does not know; make complete actions and team utility correct; preserve an explainable TypeScript truth; then let Wasm own the genuinely expensive loop. Keep that order and an AI can become faster, stronger, and capable of graceful fallback on an older device without ever needing to peek at one card to look clever.

A useful next topic is difficulty itself: how to make local AI easier without rigging the deal, scripting a loss, or forcing mistakes that no plausible player would make.

JOURNAL

Keep reading

All notes