Captain Rojo can't see your cards: testing that a game AI doesn't cheat
Every game in Peekaboard is about a secret: a hidden fleet, five dice under a cup, a hand of cards. The computer player sits at the same table. Here is how it only ever gets what a player in its seat would know, and the tests that fail if it could peek.
Peekaboard 1.2 came out on 27 October. It added Gin Rummy, the third game at the table after Naval Duel and Bluff Cups. All three are built around a secret. In Naval Duel it’s where your fleet is. In Bluff Cups, a game of liar’s dice, it’s the five dice under your cup. In Gin Rummy it’s your hand.
When you play alone, the other seat belongs to Captain Rojo, the computer player. “The computer cheats” is a very common suspicion about games on a screen, and in a game about secrets it’s the one that would hurt most. If Rojo called “Liar!” at exactly the right moment a few rounds in a row, you’d be right to wonder.
So I wanted more than a promise. I wanted the code to make cheating impossible, and tests that fail if anyone, me included, ever makes it possible again.
Rojo sits at the table, not inside the game
Each game has a full state: both fleets, all the dice, both hands, the order of the stock. Captain Rojo never gets that. Every game also has a second, smaller type that holds only what one seat is allowed to know, and the AI is a function that takes that view and returns a move.
In Bluff Cups the whole view fits in a few lines. Slightly simplified:
/// What one seat legally knows during a round. It holds no dice of the other seat,
/// so an AI that reads only this view cannot cheat.
public struct SeatView {
let rules: BluffRuleSet
let seat: Seat
let ownDice: [Int]
let opponentDiceCount: Int
let bids: [PlacedBid]
let toMove: Seat
}
Your dice aren’t in it. Only how many you have left.
Naval Duel works the same way. Rojo’s view of your sea holds the shots, the hits and misses, the ships that are already sunk, and the kinds of ships still afloat. It holds no positions. The AI also keeps no memory between shots, so it can’t build up anything the view doesn’t already give it.
Gin Rummy was the interesting one, because what a seat knows isn’t only its own hand. If you take the seven of hearts from the discard pile, the other player saw you do it. A person at a real table would remember that card, so Rojo’s view works it out from the public moves: every card you took from the pile and haven’t thrown away again. The stock is just a number. Its order isn’t in the view at all.
The rules live in small Swift packages with no interface code in them, so all of this runs as plain tests on the Mac. No simulator, no phone.
A comment is not a test
A doc comment that says “cannot cheat” is a promise. Here’s what checks it.
The bluntest test lists the fields of the view with Swift’s Mirror and compares them with a fixed list:
let labels = Mirror(reflecting: view).children.compactMap(\.label)
#expect(labels == ["rules", "seat", "ownDice", "opponentDiceCount", "bids", "toMove"])
It looks a bit silly. It’s there for the day someone adds a handy extra field, like the other seat’s dice for a debug overlay. That change can’t slip in quietly. The test fails, and someone has to decide on purpose.
The test I like best is in Gin Rummy. It plays part of a hand, then takes every card the AI’s seat can’t see, the rest of the other hand and the whole stock, and shuffles them into a different deal. The view stays exactly the same. Then it asks the AI for a move in both versions of the game, with the same random seed. The two moves have to match. If Rojo could see anything hidden, a different hidden deal would sometimes change his mind. It runs 60 games at every level.
Naval Duel has the same idea with fleets. The test sets up two games with different random fleets and gives the AI the same seed in both. As long as the two boards look the same from the outside, the shots have to be the same. An AI that knew where the ships were would split away early. It runs 300 pairs of fleets at each of the three levels.
All of that depends on one small piece: a seeded random generator in the package the games share. Every random choice the AI makes goes through it, so any match can be replayed exactly. The same trick replays a match for the App Store screenshots.

Fair, measured over thousands of games
Those tests prove Rojo can’t read hidden things through his view. They don’t prove the game is fair to begin with. A dealer who stacks the deck is as bad as a player who peeks. So a few tests only make sense in bulk:
- Bluff Cups rolls 20,000 deals and checks that every face comes up about one time in six under each cup.
- The hardest Bluff Cups AI plays itself 2,000 times. With the same player in both seats, each seat should win about half, and it does. A seat that saw the other dice would win far more.
- Gin Rummy does the same with 150 matches, with a wider allowed range because the sample is smaller.
- In Naval Duel, while the hardest AI has no hit to chase, its hit rate has to stay close to the share of cells that hold a ship. Hitting more often than chance before it has found anything would mean it was getting help.
These are the slow tests, so the Gin Rummy ones run in a release build, where they take seconds instead of minutes. Writing them also kept me honest in the other direction. Three of the first Gin Rummy scoring tests were wrong, and the engine was right every time.
Fair isn’t the same as easy
None of this makes Rojo a pushover. He plays at three levels, Deckhand, Captain and Admiral, and the top one is not gentle. He just has to win the way a good player wins.
In Naval Duel, the Admiral counts every place each remaining ship could still be, given the misses and the sunk ships, and fires at the cell that the most of those places cover. Once he has a hit, he only counts places through it.
In Bluff Cups he knows his own five dice and works out the chance that a bid is true from the dice he can’t see. Ones are wild, so each hidden die has a one in three chance of counting. The Captain and the Admiral also listen to your bids, the way a person does: if you’ve bid on sixes, they count it a little more likely that you hold one. And now and then he bluffs, bidding on a face he doesn’t have.
In Gin Rummy, before he takes the card on top of the discard pile, the Admiral compares it with every card he hasn’t seen, because that’s what could come off the stock. He only takes it for a clear gain, since taking it tells you what he’s collecting.
Everything he uses is on the table. In a test run of 200 matches, the hardest Gin Rummy level beat the easiest about nine times in ten. The easiest still has to win some, and there’s a test for that too.

What the tests don’t cover
They check the rules and the AI, not the screen. The cover screen between turns and the fold-to-peek on iPhone Duo are a different kind of hiding, about people rather than code, and I wrote about those in Hiding a fleet on iPhone Duo. For the new games, the fold-to-peek has run in Apple’s iPhone Duo simulator, not yet on a real hinge.
They also can’t tell you whether Rojo is fun to play against. That part only shows up by playing.
The table so far
Peekaboard is on the App Store for iPhone, on iOS 27 or later. Naval Duel comes with the app. The Full Pass is a one-time $4.99 purchase that unlocks Bluff Cups, Gin Rummy and every game added later, with no subscription, and you can play three matches of each before you decide. It works offline, with no account and no ads.
If you want a game for two on one phone, or a captain who keeps his eyes on his own cards, Peekaboard is on the App Store.
In a game about secrets, the computer has to keep them too.