import pandas as pd
df = pd.read_csv("simulation.tsv", delimiter="\t", names=["num_players", "strategy", "cards_played"])
df.head()| num_players | strategy | cards_played | |
|---|---|---|---|
| 0 | 1 | random | 10 |
| 1 | 1 | minimal | 6 |
| 2 | 1 | trick | 13 |
| 3 | 2 | random | 11 |
| 4 | 2 | minimal | 9 |
Lennard Berger
January 5, 2024

A while ago I was introduced to a simple card game titled The game by Steffen Benndorf.
Unlike many other games, The game has a unique touch, because it is a collaborative card game.
The rules of the game are very simple. There are four piles, two of which start at 1 and ascend to 100. The other two piles start at 100 and descend to 1. Players take turns to place cards on the piles. If they manage to put down all cards, they win. Otherwise, The game wins.
The game comes with two additional rules:
While the rules are easily explained, beating The game is not trivial. I was curious how to best approach The game in order to increase my (or our) win rate.
Luckily, Monte Carlo simulations (Kroese, Taimre, and Botev 2011) can help to shed some light onto how to become better players.
To simulate any game, we need to hold a game state. In our case, we have 4 piles (two ascending and two descending). Further we have \(n\) additional piles with the cards of our players (which we will call hands here). For our game to start we need to deal cards in turn to all the players. In Python, we could initialize the game state like so:
# Initialize the game
game = ([1], [1], [100], [100])
deck = list(range(2, 100)) # cards from 2 to 99
random.shuffle(deck)
player_hands = [[] for _ in range(num_players)]
for _ in range(num_cards):
for hand in player_hands:
hand.append(deck.pop())Once the game has been initialized, we would like to make sure the game is simulated for as long as the following conditions are met:
The two first conditions are easy to verify. If we use pop to remove cards from the deck (and player hands), eventually they should wind up empty. This can be checked with the length property:
Lastly, we need to check wether there are still cards that can be played. To do this, we introduce a game_running variable. Any time a card is picked (see strategies), we will verify wether the picked card can be played. This can be done using Python’s operator package. Our preliminary code now looks like this:
# Start playing the game
cards_played = 0
game_running = True
# The game is running so long as there are cards in the deck or player hands
while len(deck) and len(list(chain.from_iterable(player_hands))) and game_running:
for hand in player_hands:
# Choose the deck and card to play using the strategy
deck_to_play, card_to_play = strategy(
game,
hand
)
# Draw the card
card = hand.pop(card_to_play)
# Check if it is valid for the selected deck
comparator = gt if deck_to_play < 2 else lt
if comparator(card, game[deck_to_play][-1]):
game[deck_to_play].append(card)
cards_played += 1
if len(deck):
hand.append(deck.pop())
else:
game_running = False
breakWith the basic game logic in place, it is time to discuss which options players have.
For this simulation we will focus on the individual choices a player can take. While collaboration certainly is an important aspect of the game, we can create some basic strategies on a player-level. These include:
In order to implement the different strategies, it is useful to define what a strategy actually will do:
from typing import Callable
Game = tuple[list[int], list[int], list[int], list[int]]
Move = tuple[int, int]
Strategy = Callable[[Game, list[int]], Move]A strategy will be a function, which receives a game (a tuple of 4 integer lists) and a list of integers. A strategy produces a move (a tuple of integers). The move will indicate the index of the deck to play (from 0 to 3), and the index of the card to play relative to the (players) hand.
To implement the random strategy, we can enumerate all the cards in our hand, and check if they can be played. From all the valid moves, we pick one:
To get the card with the smallest distance, we first calculate the minimum (for the ascending pile) and maximum (for the descending pile) respectively. We can use the absolute function to get the smallest difference. Once we have the smallest difference, we need to find the correct pile like so:
ascending_values = [game[0][-1], game[1][-1]]
min_ascending = min(ascending_values)
descending_values = [game[2][-1], game[3][-1]]
max_descending = max(descending_values)
sorted_hand = sorted(hand)
if abs(min_ascending - sorted_hand[0]) < abs(max_descending - sorted_hand[-1]):
card_to_play = hand.index(sorted_hand[0])
if game[0][-1] == min_ascending:
deck_to_play = 0
else:
deck_to_play = 1
else:
card_to_play = hand.index(sorted_hand[-1])
if game[2][-1] == max_descending:
deck_to_play = 2
else:
deck_to_play = 3The trick strategy is similar to the minimal distance strategy, but instead of finding the smallest absolute value, we want to find values that have a difference of 10:
To finalise the code for our simulation, all we need to do is to wrap the snippets from this blog article into functions. You can find the complete code for the simulation attached.
In order to go from a simulation to a Monte Carlo simulation, we want to:
Luckily, the product function helps us do this:
from itertools import product
from simulation import simulate_game, random_strategy, minimal_distance_strategy, trick_strategy
_STRATEGY_TO_CB = {
"random": random_strategy,
"minimal": minimal_distance_strategy,
"trick": trick_strategy
}
repeats = 1000
strategies = ["random", "minimal", "trick"]
players = list(range(1, 8))
for i in range(repeats):
for n_players, strategy in product(players, strategies):
cards_played_total = simulate_game(n_players, _STRATEGY_TO_CB[strategy])
# we write a TSV file with the number of players, strategy and total cards played
print("\t".join([str(item) for item in [n_players, strategy, cards_played_total]]))The Monte Carlo simulation and output of the simulation are attached to this blog post for reproducibility.
To evaluate our simulation we need to read the output of our simulation (dataset):
import pandas as pd
df = pd.read_csv("simulation.tsv", delimiter="\t", names=["num_players", "strategy", "cards_played"])
df.head()| num_players | strategy | cards_played | |
|---|---|---|---|
| 0 | 1 | random | 10 |
| 1 | 1 | minimal | 6 |
| 2 | 1 | trick | 13 |
| 3 | 2 | random | 11 |
| 4 | 2 | minimal | 9 |
We can visualize this dataframe as a line plot indicating different types of strategy, and the gain in terms of how many cards have been played.
As one can see from Figure 1, in the random strategy, the number of players positively affects the number of cards played. Contrary to this, for the minimal and trick strategy, more players will mean less cards played.
import seaborn as sns
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8, 4))
sns.lineplot(df, x="num_players", y="cards_played", hue="strategy", ax=ax)
plt.show()
While in the random strategy more cards are played then in the minimal and trick strategy, none of the strategies can beat The game. We can verify this fact by retrieving the maximum number of cards played:
Indeed, 31 is far from the desired 98 cards. The game has won all of our \(7x3x1000 = 21000\) games.
The game claims to be a highly cooperative game, that can’t be beaten by chance. Using Monte Carlo simulations, we can show that these claims are substantial. Perhaps surprisingly though, applying the authors trick is actually counter-productive. If you play alone, playing randomly may be your best bet.
In a future episode of this blog, we may examine how collaborative strategies for the game can look, and how they can be learned using reinforcement learning.