Design

An opponent that cannot see your hand

Published 2026-09-01

Everybody who plays dominoes against a computer eventually says the same thing: it knew. It played the one tile that shut you out, twice in a row, and the obvious explanation is that it is reading your hand.

Usually that explanation is right. The cheapest way to write a decent dominoes opponent is to let it look at the whole table, your tiles included, and pick the move that hurts you most. It takes about ten lines, it plays well, and it feels wrong in a way most players can name inside three rounds.

The dominoes game on this site does something else. Here is the entire set of things its opponent is allowed to look at, copied out of the game’s own source:

const observed = {
    aiHand: hands[PLAYER_AI].map(t => ({a:t.a, b:t.b})),
    chain: chain.map(t => ({a:t.a, b:t.b})),
    leftEnd, rightEnd,
    oppCount: hands[PLAYER_HUMAN].length,
    passes,
};

Its own tiles. The line on the table, and the two numbers open at either end. How many tiles you are holding, as a bare number. That is all of it. Your actual tiles live in hands[PLAYER_HUMAN], and that array is never handed across. There is a test in the same file that asserts it, with the comment “fairness by construction”.

Guessing thirty times instead of looking once

So how does it play well without looking? It guesses, and then it guesses again, twenty nine more times.

The technique is called perfect information Monte Carlo. Each turn, the game works out which tiles are unaccounted for: the double six set of twenty eight, minus the tiles in its own hand, minus the tiles already down on the table. It shuffles that pool, deals you a hand of exactly the size it can see you holding, and now it is sitting in front of a complete game with nothing hidden in it. A game like that can be solved the ordinary way, and it is, with an alpha beta search six moves deep.

Then it throws that world away and builds another one. Thirty in total, which is a constant sitting right there in the file as PIMC_SAMPLES = 30. Every one of those searches comes back with a recommendation: this tile, on that end. The move that collects the most votes across the thirty is the move it actually plays. Not the average of them. The plurality.

What that buys is a move that holds up across a lot of different versions of you. If a tile is only good when you happen to be holding the four five, it wins two or three votes out of thirty and loses. If it is good almost regardless of what you have, it wins.

The gap you can play into

Here is the part worth knowing if you want to beat it.

That pool of unaccounted-for tiles gets rebuilt from scratch on every single turn, out of two things and nothing else: the tiles in its own hand, and the chain on the table. It carries nothing forward from earlier in the round.

Which means it has no memory of how you have been behaving. When you passed on a three two turns ago, a human opponent files that away and never leaves you a three again. This one does not. Its next thirty guesses at your hand are drawn from the same flat shuffle they would have been drawn from if you had never passed at all.

You can play into that. Hold back a tile you could legally have played, when the end it matches is one you would rather keep open for later. There is no accumulating picture of you for that to contradict, because there is no picture. It re-rolls its idea of you every turn, and the idea is always the same one: a random legal hand of the right size.

What it does know

None of this makes it naive. When the board locks and neither side can move, it scores the position the way the rules do, lower pip total takes the round, and it treats emptying its own hand as worth ten thousand. Short of those endings it prefers whatever leaves it holding fewer pips than you, with a small bonus for keeping its own options open, which is the myMoves * 0.5 term in its evaluation. And it does not stall: if none of its tiles match either open end it passes, and two passes in a row end the round on pip count.

So it is a strong opponent playing the odds honestly. When it blocks you twice in a row, what happened is that thirty separate guesses about your hand all agreed that tile was the best one available, and not one of them had seen a single tile you were holding.

Games mentioned here