Olivier Couthaud

Unstable Unicorns advisor

A rules engine, a search-plus-network AI and phone and browser apps for playing the card game Unstable Unicorns against a machine.

I've played Unstable Unicorns myself, and it's one of a few card games I've played where I could see a computer being a decent opponent. I was also curious whether a program could beat my friends, my family, or me. So I built one for the real, physical deck. It isn't affiliated with the publisher, and it doesn't use their card art or card text.

The game is about building a stable of unicorns while everyone else tries to wreck it. Hands are hidden, and any card you play can be cancelled by a Neigh card, which can itself be cancelled by another Neigh.

First I had to teach a computer the rules, all 127 cards of them. Then I needed it to choose moves. It does that with a neural network, which has learned what a good position looks like, plus some thinking ahead. Before it moves, it imagines hundreds of ways the game could continue (more on the harder levels) and picks the move that did best, like a chess player, except it has to guess which cards you're holding because it can't see your hand. It started by copying simple scripted opponents I'd written, then learned by playing against them and against itself over and over.

Real tablereal cards, real people Phone appI tell it what happened The AI's moveI play it for the AI
Diagram. At a real table the AI has no eyes or hands, so I'm both. It never asks what's in my hand, only what I visibly do.

There are a few ways to play it. The first is with the real deck: I tell a phone app what happens at the table, and it tells me what to do for the AI, so my hand stays secret. There's also a browser version with four difficulty levels, where the harder levels just think longer, and a version for three players.

How good is it? Against the simple scripted opponents, the latest version wins about 8 games out of 10, about 10 more wins in 100 than the version before it. In the three-player game it wins about half against two scripted opponents, where chance would give it a third. Those opponents aren't strong players, though, and I haven't measured the AI against people, so I can't say how it does against a good one.

The thing I didn't expect, from watching the games the AI plays, is how big an advantage it is to start first. I had no idea before. It's a lot bigger than I would have guessed.

Horizontal bar chart of win rates against four scripted bots, comparing an earlier network with the latest: about 69 to 81 percent, 72 to 83, 69 to 81, and 77 to 86
Win rate against four scripted opponents, earlier version in grey and latest in red. The bars start at 50%, so they exaggerate the gap a little.

Plenty didn't work. A long string of tweaks to the network changed nothing, and for a while I thought a better training method had failed, when really I was judging it the wrong way: my test made good versions look bad. I also built a much faster copy of the game that runs on a graphics card, to train on, but it didn't follow the rules closely enough, and training on it made the AI worse at the real game.

What's next

The thing I haven't done is the one that matters most: play it against real people and find out how strong it really is. That means my friends and family first, and then me, which is the match I'm most curious about.

What I wanted

I've played Unstable Unicorns myself, and it's one of the few card games I've played where I could see a computer taking a seat at the table. I was also curious whether a program could beat my friends, my family, or me. Mechanically it's a commercial card game with hidden hands and a reaction mechanic: any card you play can be cancelled by a Neigh, which can itself be Neighed. I wanted an opponent for the real, physical deck, and later an advisor for three-player tables. It isn't affiliated with the publisher and doesn't use their card art.

I wrote it twice. The first version, from April, got lost, and what's here is the July 2026 rewrite. I started from the official second edition rules and wrote the tests from those, not from whatever the engine happened to do. There are a few hundred of them, plus a fuzzer that plays thousands of random games and checks that no card ever appears or vanishes.

How it works

The engine is plain Python with the full 127-card deck. The decision everything else hangs on is that every choice is a decision node. Picking a target, discarding, answering a Neigh window: the engine never calls back to ask, it just reaches a state where some seat has to act. So the search, the network, a scripted bot and a person all use the same two calls, legal_actions and step, and a seat only ever sees observation(seat), with the hidden cards masked.

Game enginefull state, 127 cardsevery choice is a node One interfacelegal_actions(state)step(state, action)observation(seat) Network plus searchguesses the unseen cards, up to 1,600 sims Raw networkone forward pass, no search Scripted botsheuristic, hoarder, controller... A personthrough one of the apps
Diagram. One interface for every kind of player. That is what lets the same search drive a bot, an app and a training run.

The player is a residual network (about 8 million parameters, six blocks of width 768, 1,056 input features, a policy over 97 action slots and a value head) wrapped in determinized information-set MCTS. Search can't see your hand, so before each simulation it deals the cards it can't see at random, consistent with everything that's public, and then searches that world.

What is determinized search?

Ordinary tree search assumes it can see the whole game, which in a card game means it plans against the cards you are really holding. That is cheating. Determinization fixes it: before each simulation the search deals out the unseen cards at random, consistent with what it has actually seen, and treats that guess as the true state. After many simulations with different guesses, the moves that hold up across most of them score best. It never peeks. The known weakness is that it can't value hiding information or bluffing, because inside each guess everything is already known.

In both two-player apps every AI move goes through search. In the three-player digital game the AI drops to raw network moves once a turn's time budget is spent, so a click never hangs. In the two-player digital game the four difficulty levels are just search budgets, from 150 simulations up to 1,600. The three-player levels run 25 to 220.

Engineplus a network and searchtwo- and three-player versions Table companionreal deck, 1v1, you are the AI's hands Digital gamebrowser, 1v1 or three players, four difficulty levels Three-player advisorreal deck, you type in the board for each decision Room modetwo friends on their own phones, the AI takes the last seat
Diagram. Four ways in. They share one engine; the two-player apps run the two-player network and search, the three-player ones run the three-player network and maxn search. Only the table companion and the advisor are for a physical deck.

The table companion is the one for a real deck. The AI is seat 0 and you are its hands. The app always shows exactly one prompt, either "do this for the AI" or "what happened?", and asks only for what's visibly done, never for your hand. The engine normally skips a Neigh window when it can prove the other side has no Neigh. That proof is wrong when the other hand is hidden, so the live version opens the window whenever your unseen cards could include one. Random pulls work the same way: the engine's pick is only a guess, and you confirm the card that was really pulled. A misclick can be undone, repeatedly, from a stack of snapshots, and the win-probability bar is the value head's opinion of the AI's chances.

Physical tableyour hand stayssecret event LiveEnginehidden cards = UNKNOWNunseen-pool card countNeigh window if legalundo: snapshot stack state IS-MCTS + netdeterminize unseenup to 800 sims PromptAI says: do Xor: what happened? you do it at the table and tap Done
Diagram. The table companion loop. Unknown cards stay unknown until you report them.

Here is the Neigh pile on its own. Every response lands on top of the pile, only the top card can be answered, and when everyone has passed the pile resolves from the top down. You can try it below. It's a toy, and it leaves out Super Neigh, which can't be Neighed, and Yay, which makes everything its owner plays safe from Neighs.

A pile of Neighs (illustrative)

  1. Neigh 1 (opponent), top of the pile: resolves and stops My card
  2. My card: stopped, does nothing
My card is cancelled

Neighs alternate between the two players, starting with the opponent, because you can't Neigh your own play. Resolution goes top-down, and a resolving Neigh stops whatever is directly below it. An odd number of Neighs cancels the card. In the engine, every one of those windows is a decision node.

Results

Training started by imitating scripted bots, then moved to league self-play against scripted styles (heuristic, hoarder, controller, rusher, disruptor, with a comboer kept out of training for testing). The raw network plateaued at roughly 55% against the heuristic bot, and search added about 12 to 15 points on top. After that I ran an AlphaZero-style loop on a rented cloud box, about 50 cents of rented time for the first run and about a dollar fifty for the second.

What does AlphaZero-style training mean here?

The network plays games against itself, with search choosing the moves. Search is stronger than the network alone, so the moves it picks are better training targets than the network's own guesses. The network learns to predict the search's choices and who won, which makes the next round of search stronger, and so on. The risk is that it only learns to beat itself, so each new network has to prove it beats the previous one before it gets promoted. That promotion test is the gate in the diagram.

Self-playsearch + Dirichletnoise at the root Replay bufferlast N generations Trainpolicy: visit countsvalue: outcome mix Gatesearch ON, paired seedspromote if 95% CI > 0.5 Newself-playnet on a fail, keep the old net; stop after 3 fails in a row
Diagram. The loop, with the search-on gate I added for the second run: 60-simulation search, 200 paired games, promote when the lower end of the 95% confidence interval is above 0.5.
Win rate of the network plus 300-simulation search against scripted bots. 400 paired seeds, fresh seed set. Before is the first AlphaZero net, after is the generation-8 net. Both are scored with search.
OpponentBeforeAfter
Heuristic68.9%80.6%
Hoarder71.6%82.6%
Controller69.1%81.0%
Comboer (held out of training)76.8%86.1%
Horizontal bar chart of win rates with search against four scripted bots, comparing the first AlphaZero net with the generation-8 net: 68.9 to 80.6 against heuristic, 71.6 to 82.6 against hoarder, 69.1 to 81.0 against controller, 76.8 to 86.1 against the held-out comboer
The table as a chart. The axis starts at 50%, so bar lengths overstate the ratio between the two nets. Scripted bots only.

The comboer never appeared in training, and it moved by about 9 points, so the gain isn't the net getting used to the opponents it trained against.

The three-player version has its own network (1,342 inputs, 98 actions, about 8.6 million parameters) and maxn search. It started from imitating the heuristic bot, then self-played for 80 generations of 2,500 games each, roughly 200,000 games in one overnight run for about $3 of rented time. Each result below is 90 games with 120 simulations of search and the seats rotated, so it carries about plus or minus 10 points. Chance in a three-way game is 33%.

How does search work with three players?

Two-player search can score a position with one number, because what's good for me is bad for you. With three players that breaks, since a move can be good for one opponent and bad for the other. In maxn every position carries one score per player, and whoever is to move picks the option that's best for their own score. Nothing about teaming up is written in. Going after the leader falls out of each player looking after itself.

Three-player checkpoints against scripted bots

Win rate in three-player games against two scripted bots, either ordinary ones or ones ganging up on the net. 90 games per cell, 120-simulation search, chance is 33%.

CheckpointOrdinary botsGanging up
Generation 3046%28%
Generation 5033%29%
Generation 7046%29%
Final48%31%
Final, unseen bot style50%34%
League run (not kept)29%31%

The unseen style is a bot built on the hoarder, which the net never trained against. The league run is the one that didn't work, described below. The widest of these error bars is about 10 points either way.

With search off the three-player network was weak, about 27% against the heuristic bots at generation 30 (a small sample), against 46% with search on in the larger test. Search lifted it a lot, as it did in the two-player game. Against two bots deliberately ganging up on it, the final net held about a fair share, 31%, rather than being crushed to nothing. It didn't beat the coalition.

What didn't work

Roughly a dozen tweaks to the raw network either changed nothing or made it worse: gentle fine-tunes, bigger runs of self-play, distilling a search into the net, training from scratch, and a small AlphaZero pilot that fell to 31%. Some of those results may have been the same measuring mistake described next.

The first real AlphaZero run stopped at generation 3, on a kill rule I'd set in advance: three gate scores in a row below 50%. They were 0.371, 0.349 and 0.393. The gate was wrong, not the net. It scored the raw policy sampled at temperature 1, and a net trained on search results has a flatter policy, so sampling from it plays loosely.

What does temperature mean for a policy?

A policy is a list of probabilities over moves. Sampling at temperature 1 picks each move in proportion to its probability, so a flat policy sometimes picks moves it ranks low. Turning the temperature towards 0 makes it pick its top move nearly every time. Training towards search results made this net's policy about twice as flat (entropy from 0.41 to 0.82), yet it still picked the same top move 90% of the time. So the net got no worse. It only looked worse when it was sampled loosely.

Two nets, judged three ways

Win rate against the heuristic bot, same seeds for both nets. Before is the net I started from. After is the first search-trained net, the one the gate rejected. Temperature 1 samples moves in proportion to the policy, temperature 0.2 is near-greedy.

Judged byBeforeAfter
Temperature 153.7%43.3%
Temperature 0.257.2%56.8%
300-sim search60.2%70.0%

The first row is what the gate was measuring. The last row, search on, is how the net is actually used. The bars start at zero.

I changed the gate to play with search, and the second run promoted four times (generations 2, 4, 5 and 8) before it hit its own kill rule at generation 11. The net from the first run was safe to install in the meantime, because every move in the apps goes through search, so the weakness never shows up in play.

Separately, the exploration noise in that first loop turned out to be degenerate: 24 distinct noise vectors across 112 decisions, when there should have been 112, because the random generator was keyed on the batch index. Two test suites had passed it. A check that can only pass can't catch the failure it's guarding, so every check I add now gets a test that deliberately breaks it.

Some dead ends. I tried giving the net memory with a recurrent layer. To check whether it would help, I tried to identify an opponent's style late in a game: ten hand-made running counters (how many Neighs they've played, whether they passed while holding one, and so on) got it right 67% of the time, a trained recurrent net 31%, and always guessing the most common style 37%. So I dropped recurrence, and adding the counters to the net didn't move its strength either.

A gated league run for three players ended worse than the net it started from: free-for-all 48% down to 29%. The gate used only 50 games, which promoted noise, and training against scripted gang-up opponents seems to have hurt its general play. I kept the original.

I also built a GPU copy of the game to train on. The first version reached about 1,577 random-policy games a second on a rented RTX 3090, but when I checked it card by card, only 23 of the 84 distinct cards behaved exactly like the real engine, and a net fine-tuned on it dropped from about 53% to 23% against the heuristic bot in the real game. I rebuilt it card by card against written specs, until two nets playing each other scored the same on both engines within noise (0.563 against 0.537, each plus or minus 0.056). That made it several times slower per game, and it still can't run tree search, so the AlphaZero runs used the real engine on CPUs.

What is unfinished

Every number above is against scripted bots or earlier versions of the net. I haven't measured it against people, so I don't know how it does against strong ones. What I want next is to put it in front of my friends and family, and to play it myself. One thing the AI's games already showed me is that starting first is a large advantage, much larger than I expected from playing the game by hand. The digital game logs every game it plays, which is where a first human benchmark would come from. The three-player advisor is stateless: you retype the board for each decision. A few rules still differ from the real game. The main one: beginning-of-turn effects resolve one by one in an order the player picks, instead of all at once. The order of other simultaneous triggers is also fixed, and Tiny Stable fires once per entry.

Stack
Python, PyTorch, FastAPI, rented cloud boxes for training
Apps
Table companion for a physical deck, a digital game with difficulty levels, a three-player advisor, a room mode for two friends
Status
Working
Code
Not public

← All work