How I built a video game (I make board games)
How Gallery Gambit's rules, turns and scoring became code, and how the three bot difficulties actually think, from rulebook to app.
Hey, last time I introduced the online version of Gallery Gambit, but I did not give you enough details on how the game actually plays. That’s why I am sharing a quick follow up with a how-to-play video. I will also take you with me through the process I went through while building the three apps.

Because you can play the game in your browser or via the native Android or iPhone apps.
There are already a bunch of people playing the daily challenge, even without the how-to-play video, which is wonderful to see! The written rules are in the apps.
How to play
Check out this instructional how-to-play video if you want to learn how the game works in 2:30 minutes.
How I made it (for the techies)
It all started with the rules. The rules need to be implemented for the logic of the game. There are roughly three types of rules:
- Fixed numbers. For example, the number of cards an Exhibit can hold. Exhibit A holds 2 cards, B holds 3, C holds 4. This becomes a named box holding those numbers.
// arithmetic, in one file
const SLOT_CAPACITIES = {
A: 2, B: 3, C: 4 // how many fit
};
const SLOT_BATTLE_BONUS = {
A: 2, B: 3, C: 4 // winning bonus
};
const MAX_ROUNDS = 3;
// revealed at round end
const JOKER_PHASE_OPEN_CARDS = 8;
- Yes or no questions. These become a function. So, to determine whether a set is of the Same Rank (e.g. all 10s):
function isSameRank(cards) {
return cards.every(
card => card.rank === cards[0].rank
);
}
- “Which is better?” types of questions. They become a comparison. For example the rule is: A bigger Set beats a smaller one. Same size? Same rank beats a run.
function whichWins(a, b) {
// more cards wins
if (a.size !== b.size)
return a.size - b.size;
// else better type wins
return RANK[a.type] - RANK[b.type];
}
The trick is to compare one thing at a time, in the order of the rules.
The setup of the game
For the setup, you have to determine the number of players, and which cards should be removed from the deck at that player count.
And of course the deck needs a good shuffle. Then cards are dealt and a Market is placed.
On your turn
On your turn, do exactly one of: Draft & Play, Draw & Play, or Gambit.
// a checklist
function validateDraftAndPlay(
state, player, marketCardId,
playCardId, slotLabel
) {
if (!isCurrentPlayer(player))
return no('NOT_YOUR_TURN');
if (player.drawnCard)
return no('MUST_COMPLETE_DRAW');
if (!marketHas(marketCardId))
return no('CARD_NOT_IN_MARKET');
if (!isTheirs(playCardId))
return no('INVALID_CARD_TO_PLAY');
const slot = player.slots.find(
s => s.label === slotLabel
);
if (!slot) return no('INVALID_SLOT');
if (slot.cards.length
>= slot.capacity)
return no('SLOT_FULL');
return yes();
}
Read it aloud: your turn? not mid-draw? that card really in the Market? that card really yours? is that Exhibit real? does it have room? Only then is the move allowed.
Round end
It has to know when the Round ends. This is triggered when one of the players has a full Exhibit. And then it has to accommodate for exchanging the Joker with one of 8 open cards.
Reveal cards until 8 are open, counting the Market. Then, in turn order starting from the Starting Player, each Joker must be exchanged for one of them.
// Room.ts, enterJokerSwap()
// The market becomes part of the open
// cards, but a Joker sitting in the
// market is set aside, not offered.
// Swapping a forgery for a forgery
// would defeat the whole point of the
// mandatory exchange.
const openCards = market.filter(
card => !isJoker(card)
);
while (openCards.length < 8
&& deck.length > 0) {
const card = deck.shift();
// skip it, keep revealing
if (isJoker(card)) continue;
openCards.push(card);
}
And then it determines who can swap first and how this works:
for (let k = 0; k < playerCount; k++) {
// turn order, wrapping
const seat =
(startingSeat + k) % playerCount;
// A, then B, then C
for (const slot of player.slots) {
slot.cards.forEach((card, i) => {
if (isJoker(card)) queue.push({
seat,
slot: slot.label,
cardIndex: i
});
});
}
}
Scoring
Determining the base points: Same rank and Run in the same suit score 2 points per card. Run and Same suit score 1 point per card.
const SET_POINTS_PER_CARD = {
'same-rank': 2,
'run-same-suit': 2,
'run': 1,
'same-suit': 1,
};
points = numberOfCardsInSet
* SET_POINTS_PER_CARD[type];
The game has to determine if the cards in an Exhibit are actually a set:
function classifySubset(cards) {
// a Set is 2+ cards
if (cards.length < 2) return null;
// jokers form no sets
if (cards.some(isJoker)) return null;
if (allSameRank(cards))
return 'same-rank';
if (isRun(cards)
&& allSameSuit(cards))
return 'run-same-suit';
if (isRun(cards)) return 'run';
if (allSameSuit(cards))
return 'same-suit';
// not a Set at all
return null;
}
and then it finds the best set:
let best = NO_SET;
for (const subset of
everyCombinationOf(cards)) {
const type = classifySubset(subset);
// not a Set, ignore it
if (!type) continue;
const candidate = {
type,
size: subset.length,
points:
subset.length * pointsPer[type],
};
if (isBetter(candidate, best))
best = candidate;
}
return best;
The tricky part is your points come from the subset worth the most points. An Exhibit battle is fought with the subset holding the most cards. They are frequently different sets of the same cards.
10♥ 9♥ 7♥ scores as a two-card run-in-suit (4 points) but in the battle is considered as a three-card same-suit, beating an opponent’s two-card Set.
detectScoringSet(cards) =
searchWith(mostPointsWins);
detectBattleSet(cards) =
searchWith(mostCardsThenTypeWins);
And this is how the game itself was made.
The bots (still for the techies)
A bot has one job: pick the best move. The three difficulties are three different ways of picking one.
Normal looks at every move it is allowed to make, gives the resulting board a score, and takes the highest. No looking ahead. It answers in under a millisecond and it is simply very sure of itself. It doesn’t predict anything.
Hard cannot see your hand, and it does not know the deck order. So it guesses. It deals out a version of the hidden cards that fits everything it can see, plays that version forward, and notes who wins. Then it guesses again, differently. Twelve guesses per move. It plays whichever move survived most of them. When it feels like the bot has read you, it has imagined twelve of you.
Brutal does the same twelve guesses, but stops playing each one out early and asks a trained model instead: how good is this board, really?
That model is where it gets fun. I let the game play itself many thousands of times overnight. Every finished game left evidence: here is a board, and here is how it ended. The model studies that pile until it can judge a board it has never seen.
The bot only got approved once it was able to beat the “Hard” bot consistently. The version that is live won 72.8% of those games heads-up.
model + search
this is Brutal
v model missing or broken
search only
still strong, this is Hard
v search unavailable
judgement only
instant, this is Normal
v anything else at all
simple greedy move
always available
Can you beat them? Try it out and let me know.
- Robin
The Kickstarter launches September 28. If you want to be there on day one, and help other people find the game, following the page is the single most useful thing you can do.
Written by Robin Stokkel for Four Suit Studio.
