Arthur Gao / Index Project 03 of 06
← Index 03 / 06

Shengji AI

Teaching a machine to play Tractor: self-play, a card transformer, and search under hidden information

Summary
A from-scratch Shengji (升级 / Tractor) engine in Rust and an AI that learns to play it through self-play. A small transformer over cards, trained with Deep Monte-Carlo in PyTorch, beats my hand-written heuristic bot by a clear margin on an Elo ladder, and endgame PIMC search adds roughly another 25 points per deck on top.
Type
Personal side project
Timeframe
June – July 2026, ~3 weeks
Engine
Rust workspace: core, env, sim, ai, TUI, PyO3 bindings
Learning
PyTorch, Deep Monte-Carlo self-play, single RTX 5090
Team
Arthur Gao

Why Shengji Is Hard

Shengji (升级, also called Tractor or 八十分) is a Chinese trick-taking game. Four players in two fixed partnerships play with two full decks, 108 cards, and fight over the 5s, 10s and Kings: 200 points per deck. The defending team tries to keep the attackers below 80. Win and your team climbs a level, from 2 toward Ace.

It is a far nastier problem for an AI than chess, for four reasons.

Hidden information

You see 25 of your own cards and whatever has been played. The other three hands, and the eight-card kitty the dealer buried, are hidden. Any decision is a bet over thousands of possible deals, and a good player is constantly inferring who is void in what.

Trump changes every round

The trump suit is set by a bidding race during the deal, and the trump rank is whatever level the defenders are on. Every round reshuffles which cards are strong. With 13 ranks and either a suit or no-trump, there are 65 trump specifications, and a strategy that is excellent under one can be terrible under another.

Team play

You can't talk to your partner, but you can signal through what you play: feeding points to a partner who is winning the trick, or leading a suit they are void in. Score is shared, so a move that is bad for you can be great for the team.

Tractors and throws

Leads aren't single cards. You can lead a pair, a tractor (consecutive pairs, like 7-7-8-8), or a throw that combines several units at once, and followers have to match the shape as far as they can. The legal action space is combinatorial, and the engine has to enumerate it exactly. An early version ran out of memory on follow generation before I rewrote it constructively.

Architecture

The split is simple: Rust owns the game, Python owns the learning.

shengji-core is a pure, deterministic state machine for the full rules: dealing, bidding, kitty exchange, trick legality, throw adjudication and scoring. It does no I/O and uses no randomness or threads, so the same engine runs in a seeded simulator, a ratatui terminal client and the training loop. Every rule has tests, and a self-play harness runs thousands of seeded games checking invariants (point conservation, hand sizes, legal follows).

shengji-env wraps the engine as a step-able RL environment and encodes observations and actions as tensors. shengji-py exposes a batched VecEnv to Python through PyO3, parallelised with rayon, so one call steps hundreds of games. The trained network goes back into Rust for evaluation: a pure-Rust transformer forward pass that matches PyTorch to 1.9e-6, so the learned bot plays on exactly the same ladder as the hand-written ones.

Shengji AI pipeline The Rust engine feeds a batched environment to PyTorch self-play training. The trained transformer is exported back to Rust, where it drives PIMC search and plays on the Elo league. RUST PYTHON / PYTORCH shengji-core rules, legal moves shengji-env encode, determinize VecEnv (PyO3) 512 games / batch DMC self-play card transformer, Q(s,a) Rust inference SJT1 weights, greedy PIMC endgame search Elo league + CIs export
Fig. 01 — The pipeline. Rust plays the games, PyTorch learns from them, and the learned net goes back into Rust to be searched over and measured.

Why a set-style transformer fits cards

A hand is a set, not a sequence. There is no meaningful order to your 25 cards, and with two decks most cards appear twice. A plain MLP over a flat vector has to learn every card relationship from scratch. Attention lets each card look at every other card directly, which is what Shengji strategy is about: do I hold the pair above this pair, is this King still protected, which suits am I short in.

Before the network sees anything, cards are encoded relative to the current trump: by role (the strongest trump, the second-strongest, the off-suit Ace) rather than by identity. My model treats the 64 canonical card slots as tokens. Each token carries feature planes (copies in my hand, copies already played, cards in the current trick, the lead, the kitty if I'm the dealer, point value), plus a learned slot embedding so it knows that slot 5 sits just below slot 6 in strength. The model is 3 pre-LN encoder layers, d_model 128, 4 heads and about 484k parameters.

It is encode-once, score-many: the transformer runs once per decision to produce a summary token, then a small MLP head scores each legal action against that summary. That matters, because a lead with throws can have dozens of legal options.

Training

The method is Deep Monte-Carlo (DMC), the family behind DouZero for Dou Dizhu. All four seats are played by the same network. Every legal action gets scored, the best is taken (with epsilon exploration, 0.12 decaying to 0.02), and once the round ends every (state, action) pair is regressed toward the final return for that seat. There is no hand-labelled data and no search during training. The net learns entirely from playing itself.

The champion trained for 40,000 iterations of about 50,000 decisions each, roughly 13 hours on one RTX 5090. The GPU sat at about 20% utilisation the whole time. The bottleneck was Rust stepping the games, not the network, so the biggest speedups came from the environment side: rayon-parallel batches (2.7× faster collection) and keeping action selection on the GPU.

What worked and what didn't

Canonicalization worked. Encoding cards by trump-relative role, instead of by identity, was the biggest single jump when training across many trump specs. It lets one suit's training carry over to the other three for free.

Ranks don't canonicalize. Suits are symmetric, but ranks aren't. Points are pinned to 5, 10 and King, so moving the trump rank changes how scoring interacts with trump strength. A net trained only at trump rank 2 played worse than random at Ace trump. Each rank is a partly separate game.

A variance-reducing baseline made things worse. Subtracting a learned state value from the return is textbook variance reduction. In a matched A/B it left the net about 53 points per deck worse. The value head explained most of the return, the leftover advantage was mostly hidden-card noise, and the Q-net fell back to predicting the mean. Here the high-variance return was carrying the signal the representation needed.

Handing the net card-counting features hurt. Giving it explicit per-opponent void flags, the same signal my heuristic bot relies on, did worse than leaving them out (single seed). The net seems to build a richer opponent model when it has to infer voids itself.

More compute stopped helping. Training the champion for another 15,000 iterations bought nothing measurable: 0.509 head-to-head [0.495, 0.523] over 4,800 paired decks. A bigger MLP didn't help either. Representation mattered much more than scale.

Once the network plateaued, the next lever was search. Perfect Information Monte Carlo (PIMC) is the classic approach for hidden-information card games. At a decision point:

  1. Sample N full deals consistent with everything I know: my hand, every card played, every void inferred from failures to follow, hand sizes.
  2. For each legal move, force it in every sampled world and play the rest of the round with the network for all four seats.
  3. Average the outcome per move and pick the best for my team.

The sampler runs at about 730k worlds per second, so it is never the bottleneck. Search only switches on in the endgame, from around trick 10 to 13. Before that, the branching is huge and searching adds nothing measurable. At N = 64 worlds and the last 13 tricks, a move takes a median 69 ms on the GPU, fast enough for real-time play.

Known weaknesses of PIMC

PIMC has well-known flaws. Strategy fusion: it solves each sampled world as if it could see all the cards, so it assumes it can take different actions in worlds it can't actually tell apart. Non-locality: it ignores that opponents' earlier plays were themselves informed by their hands. No information hiding: it never chooses a move to keep the opponents guessing. In a partnership game that also means it can't reason about signalling to a partner.

I tested for these directly instead of assuming them away. If strategy fusion were biting, searching earlier in the round should eventually start to hurt. It never did: the gain kept growing down to the last 22 tricks. Rolling out with a different, equally strong net than the actual opponent cost 0 to 2 points, which is noise. PIMC added the same +20 to +25 against a different-architecture opponent as against a copy of itself, so the edge isn't self-exploitation.

Two expensive ideas turned out to be dead ends. A perfect-information "oracle" network as the rollout policy was a wash at every depth. Going beyond 64 sampled worlds didn't help. I also prototyped an exact alpha-beta endgame solver: 12.7 ms median with 4 tricks left, but 3.4 seconds with 6 left. Too slow for real-time play, but viable for generating offline training labels.

Evaluation

Card games are noisy. A single deal can swing 100 points on luck alone, so I put most of the project's rigour into measurement. Every comparison uses duplicate deals: each deck is played twice with the teams swapped, and only the difference counts. The dealer rotates through all four seats. For play-only comparisons every bot buries the kitty with the same default rule, so I'm measuring trick play, not burial. Ratings come from a Bradley-Terry / Elo fit over a round-robin, with bootstrap 95% confidence intervals, and every result is logged with its git SHA.

That rigour paid for itself. Halfway through, an audit of my own results found a bug that silently truncated input width, so stale checkpoints scored as garbage. It also found claims I had chained through transitivity instead of measuring head-to-head. I fixed the bugs, rebuilt the eval, and re-measured everything below directly.

Elo league

AgentElo95% CI
Transformer (DMC, 40k iters)1962[1934, 1997]
Heuristic bot (hand-written)1806[1780, 1840]
MLP (DMC)1231—
Random legal play1000anchor

The heuristic bot isn't a strawman. It counts cards, tracks voids and beats random by about 95 to 102 points per deck. The transformer beats it in about 72% of paired games. Measured directly, the margin is +17.9, +12.6 and +21.0 points per deck at trump ranks 2, 5 and 8, all with CIs excluding zero. At Ace trump it is only +2.1 [−2.1, +6.3], which is inconclusive.

PIMC on top of the network

Search coversGain (pts / deck)95% CI
Last ~4 tricks+1.1[+0.8, +1.4]
Last ~7 tricks+6.3[+5.1, +7.4]
Last ~10 tricks+12.7[+11.0, +14.4]
Last ~13 tricks+20.3[+18.0, +22.5]
Last ~16 tricks+25.3[+23.0, +27.7]
Last ~22 tricks+31.3[+27.0, +35.6]

Against the plateaued net playing greedily, PIMC adds about 25 points per deck out of 200 when it searches the last 16 tricks. Most of the value is in the middle of the round, tricks 10 to 18, where the fight over points and trump happens. The opening is almost worthless to search, which is convenient, because it's also the most expensive part.

Caveats: all search results so far are on the four trump ranks the champion trained on. PIMC hasn't yet been rated against the heuristic bot on the league, and there is no human or external baseline in the harness yet. So I can say the bot is clearly stronger than my heuristic, not that it is superhuman.

  1. Last ~4 tricks+1.1
  2. Last ~7 tricks+6.3
  3. Last ~10 tricks+12.7
  4. Last ~13 tricks+20.3
  5. Last ~16 tricks+25.3
  6. Last ~22 tricks+31.3
Fig. 02 — PIMC gain over greedy play as search reaches further back in the round

What I'd Do Next

  • Distil the search back into the net. PIMC is a fixed bonus on a frozen net. An AlphaZero-style loop (search, imitate the search, search again with the better net) is the only path past the plateau, and it's the experiment that decides whether this gets to superhuman. I've built the label collector; the run hasn't happened yet.
  • Cover the whole game. I've since trained generalist nets for all 13 suited ranks and for no-trump, and shipped them to a client. They still need to go on the league, and the bot still doesn't bid. It plays the hand and buries the kitty, but trump selection is where a lot of real game value lives.
  • A real PIMC bot in Rust. Port the search into the Rust harness so it can be rated against the heuristic on the same Elo ladder, and use an exact solver for the last few tricks.
  • Teach it to signal. Partnership is the least-exploited part of the game. One idea: train each team as a single shared-information player, then explicitly reward plays that make a partner's beliefs more accurate.

Index(06)

Projects
  1. 01

    Whispers (Interactive / AI / Story)

    Pickford AI
  2. 02

    Big Band (Agents / Merge Orchestration)

    Pickford AI
  3. 03

    Shengji AI (Rust / PyTorch / Self-play)

    Side project

    You are here.

  4. 04

    Embedded Chess (Embedded / Rust / PCB)

    CSC385
  5. 05

    Projection Mapping (Web / Animation / Education)

    CSC490
  6. 06

    GPT Parsing (Data / GPT-4 / Research)

    SCOTUS data