Analyzing the Dynamics of Rummikub Through Python Simulation: A Probabilistic and Combinatorial Analysis

Exploring the Mathematical Depths of Rummikub with Code-Driven Insights and Combinatorial Game Analysis

Thomas Konstantinovsky
The Pythoneers
13 min readJul 31, 2024

--

Photo by Boitumelo on Unsplash

Rummikub is a popular tile-based game that blends elements of rummy and mahjong. While human strategy greatly influences the outcome of the game, this analysis seeks to explore the fundamental dynamics and boundaries of Rummikub by simulating games where moves are made randomly. By understanding the probabilistic and combinatorial aspects of the game, we can gain insights into its intrinsic complexity and behavior.

In this blog post, we will:

  1. Explain the custom rules of the game used for our simulation.
  2. Walk through the simulation framework written in Python.
  3. Derive probabilistic and combinatorial equations to explain the game’s dynamics.
  4. Analyze the results from our simulations to draw meaningful conclusions.

Custom Rules of Rummikub

Our custom version of Rummikub involves 4 players, each starting with 14 cards. The objective is to have only valid groups and sequences in hand. Each turn, a player must either take the card passed by the previous player or discard it and draw a new card from the stack. If the player’s hand consists only of valid groups and sequences after discarding a card, they win. A valid group is three or four cards of the same number but different colors, and a valid sequence is a series of at least three consecutive numbers of the same color. The game ends when fewer than 2 players are left.

Explanation of the Simulation Framework

Card Class

The Card class defines a card with a color and number and includes methods to compare cards, check for sequences, and represent the card as a string.

class Card:
def __init__(self,color,number):
self.color = color
self.number = number
def __eq__(self,other):
return self.color==other.color and self.number == other.number
def __hash__(self):
return hash((self.color, self.number))
def same_color(self,other):
return self.color == other.color
def same_number(self,other):
return self.number == other.number
def is_prev(self,other):
if self.number == 1 or self.color!=other.color:
return False
else:
return self.number==other.number-1
def is_next(self,other):
if self.number == 13 or self.color!=other.color:
return False
else:
return self.number+1==other.number

def __repr__(self):
return f'|( {self.color},{self.number} )|'
def __str__(self):
return f'|( {self.color},{self.number} )|'

Stack Class

The Stack class represents the deck of cards, including methods to draw cards, reinsert cards, and shuffle the deck.


class Stack:
def __init__(self):
self.cards = [Card(color,number) for color,number in list(product(['Red','Orange','Blue','Black']*2,list(range(1,14))))]
random.shuffle(self.cards)

def draw(self,k=1):
return [self.cards.pop() for _ in range(k)]

def reinsert(self,card):
if type(card) == Card:
self.cards.insert(0,card)
elif type(card) == list:
for c in card:
self.reinsert(c)
assert len(self.cards) < 105 # there are 104 cards without the jokers

def shuffle(self):
random.shuffle(self.cards)
def __len__(self):
return len(self.cards)

Sequence Class

The Sequence class manages sequences of cards, providing methods to add cards to sequences and find all possible sequences within a set of cards.

class Sequence:
def __init__(self,sequences):
self.cards = sequences

def __repr__(self):
return '->'.join([str(i) for i in self.cards])

def add_card(self,card_2_add):
for pos,card in enumerate(self.cards):
if card_2_add.is_prev(card):
self.cards.insert(pos,card_2_add)
return True

if self.cards[-1].is_next(card_2_add):
self.cards.append(card_2_add)
return True
return False

def __iter__(self):
self._index = 0
return self

def __next__(self):
if self._index < len(self.cards):
result = self.cards[self._index]
self._index += 1
return result
else:
raise StopIteration

@staticmethod
def find_sequences(cards):
sorted_sampled = sorted(cards,key = lambda x: (x.color,x.number))
grouped = groupby(sorted_sampled,key=lambda x: x.color)
grouped = {key:list(value) for key,value in (grouped)}
sequences = []
for color in grouped:
cards = grouped[color]
agg_seq = []
for card in cards:
if len(agg_seq) == 0:
agg_seq.append(card)
else:
if agg_seq[-1].is_prev(card):
agg_seq.append(card)
else:
if len(agg_seq) >=3:
sequences.append(agg_seq)
agg_seq = [card]
else:
agg_seq = [card]
return [Sequence(i) for i in sequences]

Group Class

The Group class handles groups of cards, including methods to validate and add cards to groups, and find all possible groups within a set of cards.

class Group:
def __init__(self,cards):
self.cards = cards
self.group_number = list(set([i.number for i in self.cards]))[0]
self.group_colors = set([i.color for i in self.cards])

@staticmethod
def is_valid(self,cards):
if len(cards) not in [3,4]:
return False
group_number = set([i.number for i in cards])
if len(group_number) != 1:
return False
group_colors = set([i.color for i in cards])
if len(group_colors) not in [3,4]:
return False
return True

def add_card(self,card):
if len(self.cards) == 4:
return False
else:
if card.number == self.group_number and card.color not in self.group_colors:
self.group_colors.add(card.color)
self.cards.append(card)
return True
else:
return False


def __iter__(self):
self._index = 0
return self

def __next__(self):
if self._index < len(self.cards):
result = self.cards[self._index]
self._index += 1
return result
else:
raise StopIteration

@staticmethod
def find_groups(cards):
groups = []
for color,group in groupby(sorted(list(set(cards)),key=lambda x:x.number),key=lambda x:x.number):
group_elemnts = list(group)
if len(group_elemnts) > 2:
groups.append(Group(group_elemnts))
return groups

def __repr__(self):
return '&'.join([str(i) for i in self.cards])

Player Class

The Player class represents a player in the game, including methods for drawing cards, removing cards, forming sequences and groups, and taking a turn.

class Player:
def __init__(self,stack,initial_hand_size = 14,id=None):
self.id = id
self.hand = []
self.groups = []
self.sequences = []

self.max_cards = 26
self.initial_hand_size = initial_hand_size
self.win = False
self.stack = stack

# draw initial hand of 14 cards
self.draw_initial_hand()


def draw(self,k=1):
self.hand += self.stack.draw(k)

#extract groups
self.groups += Group.find_groups(self.hand)
for group in self.groups:
for card in group:
self.hand.remove(card)

# extract sequences
self.sequences += Sequence.find_sequences(self.hand)
for seq in self.sequences:
for card in seq:
self.hand.remove(card)

def draw_initial_hand(self):
self.draw(k=self.initial_hand_size)

def remove_card(self,card,stack):
self.stack.reinsert(card)


def try_to_add_to_groups(self,given_card):
for group in self.groups:
if group.add_card(given_card):
return True
return False
def try_to_add_to_sequences(self,given_card):
for seq in self.sequences:
if seq.add_card(given_card):
return True
return False

def try_form_new_sequences(self,given_card):
new_sequences = Sequence.find_sequences(self.hand)
if len(new_sequences) > 0:
self.sequences += new_sequences
for seq in new_sequences:
for card in seq:
if card in self.hand:
self.hand.remove(card)
return True
return False
def try_form_new_groups(self,given_card):
#extract groups
new_groups = Group.find_groups(self.hand)
if len(new_groups) > 0:
# there is a new group
self.groups += new_groups
for group in new_groups:
for card in group:
if card in self.hand:
self.hand.remove(card)
return True
return False



def try_forming_new_sequences_of_groups(self,given_card,group_preference = True):
# try to add to hand and form new groups/sequences
self.hand.append(given_card)
if group_preference:
state = self.try_form_new_groups(given_card)
if state:
return state
state = self.try_form_new_sequences(given_card)
if state:
return state
else:
state = self.try_form_new_sequences(given_card)
if state:
return state
state = self.try_form_new_groups(given_card)
if state:
return state
self.hand.remove(given_card)
return False

def put_back_and_draw(self,given_card):
self.stack.reinsert(given_card)
self.stack.shuffle()
return self.stack.draw(1)[0]

def test_win(self):
if len(self.hand) == 0:
return True
else:
return False

def turn(self,given_card,card_drawn=False):
# check if the card is of use to hand
card_used = False

# random choice between where to add the card, to a group first (prefer groups) or to a sequence

if random.random() >0.5: # group preference
card_used = self.try_to_add_to_groups(given_card)
if not card_used:
card_used = self.try_to_add_to_sequences(given_card)
if not card_used:
card_used = self.try_forming_new_sequences_of_groups(given_card,group_preference=True)
# if true also check if long sequence can be formed
else: # prefer to test for sequece first
card_used = self.try_to_add_to_sequences(given_card)
if not card_used:
card_used = self.try_to_add_to_groups(given_card)
if not card_used:
card_used = self.try_forming_new_sequences_of_groups(given_card,group_preference=False)
# if true also check if long sequence can be formed

# else dont use it, card get back to the stack, and draw new card
if not card_used: # card was not used
#decide randomly if to keep or take card from stack and despose this one:
if random.random() > 0.5 and not card_drawn:
# draw new card
new_card = self.put_back_and_draw(given_card)
card,status = self.turn(new_card,card_drawn=True) # try with new card
return card,status
else:
# select random card to discard to next player
self.hand.append(given_card)
discard = pop_random_element(self.hand)
return discard,self.test_win()
else: # card was used
discard = pop_random_element(self.hand)
if discard is None: # non elements in hand all in sequence of groups
discard = self.stack.draw(1)[0]
return discard,self.test_win()

Game Class

The Game class manages the overall game, including methods to play rounds, track the game state, and log the game's progress.

class Game:
def __init__(self,players=4,hand_size=14):
self.turn_counter = 0
self.player_pointer = 0
self.n_players = players
self.stack = Stack()
self.hand_size = hand_size
self.players = [Player(stack=self.stack,id=i,initial_hand_size=hand_size) for i in range(1,self.n_players+1)]

self.last_given_card = None
self.log = []

def play_round(self):
if self.turn_counter == 0:
# first round
self.last_given_card = self.stack.draw(1)[0]

self.last_given_card,player_won = self.players[self.player_pointer].turn(self.last_given_card)
if player_won:
self.players.pop(self.player_pointer)
self.n_players-=1



if self.n_players >= 1:
self.player_pointer = (self.player_pointer+1)%self.n_players
self.turn_counter+=1

log = {'turn':self.turn_counter,'n_players':self.n_players,'player_turn':self.player_pointer+1,
'free_cards':len(self.players[self.player_pointer].hand),
'n_groups':len(self.players[self.player_pointer].groups),
'n_sequences':len(self.players[self.player_pointer].sequences),
'max_sequence_length':[0]+[len(i.cards) for i in self.players[self.player_pointer].sequences]

}
self.log.append(log)

Pre-Simulation Probabilistic and Combinatorial Analysis

Here, we will go deeper into the probabilistic and combinatorial aspects of Rummikub. By understanding the likelihood of certain events and the combinatorial structure of the game, we can gain insights into its dynamics and boundaries.

Our main goal currently is to get a grasp on some of the fundamental properties of the game and try to get a crude definition of these properties, as we will later see, there is no easy way to define and analytically solve the complex combinatoric dynamics of the game,

Probability of Drawing a Specific Card

When a player draws a card from the stack, the probability of drawing any specific card can be expressed as:

The naive definition assumes each copy of the same number and color is unique

Given there are 104 cards without jokers in a standard deck (13 numbers, each appearing twice, and each card has 4 colors), the probability of drawing a specific card initially is:

imposing the above equation

As the game progresses and cards are drawn, the total number of cards in the stack decreases, thereby changing this probability.
It's important to note: there is aconstant lower bound of cards left in the deck as at any given time, if a card is not used by a player, and instead they draw a new one, that card goes back to the stack.

This promises us that as long as the number of players times the hand size of each player is not greater than 104, the stack will never deplete.

Expected Number of Groups

A valid group in Rummikub consists of 3 or 4 cards of the same number but different colors. To calculate the expected number of groups that can be formed from a given hand of 14 cards, we need to consider the number of ways to pick 3 or 4 cards of the same number and different colors.

Calculating Valid Groups

First, let’s determine the number of valid groups for each number. With 4 colors and each number appearing twice in each color, the total number of ways to form a group of 3 or 4 cards of the same number with different colors from 8 cards is:

Number of way to form valid groups

Thus, for each number, the total number of valid groups is:

Valid groups per number

Since there are 13 different numbers, the total number of valid groups across all numbers is:

Total Combinations

The total number of ways to choose 3 or 4 cards out of 14 is:

Total number of combinations

Expected Number of Groups

The expected number of groups that can be formed from the initial hand of 14 cards is:

Expected number of groups per first draw of 14 cards

This means that, on average, a player can expect to form about 1.2 groups from their initial hand of 14 cards.

Probability of Forming a Sequence

A valid sequence consists of 3 or more consecutive numbers of the same color. The probability of forming a sequence from a hand of 14 cards can be calculated by considering the number of ways to pick 3 or more consecutive cards of the same color:

Our target equation formulation for this problem

The number of valid sequences is given by:

Calculating the valid number of sequences

The total possible combinations of 3 or more consecutive cards from a hand of 14 cards are:

the total combinations

Calculating this sum:

The sum calculation of the above

Thus, the probability of forming a sequence is:

probability of forming a sequence

Simulation Results and Analysis

Distribution and Cumulative Distribution of the Number of Turns to Game End

The top panel displays the PDF of the logarithm of the number of turns it takes for a game to end, with fewer than two players remaining. The x-axis is on a logarithmic scale, which helps visualize the wide range of turn counts. The distribution indicates a peak around 100 turns, suggesting that most games tend to conclude around this mark.

The bottom panel shows the CDF of the number of turns until the game’s end. The CDF illustrates how the probability accumulates as the number of turns increases. Notably, the red dashed line at 100 turns indicates that only 4% of the games end within this short span, highlighting that quick games are relatively rare. Conversely, the green dashed line at 500 turns shows that 90% of the games conclude within this period, indicating that it is quite likely for a game to end within 500 turns. This analysis helps in understanding the typical duration of a Rummikub game under the given simulation conditions.

Distribution of the Number of Groups and Sequences in the Initial Hand

The top panel displays the probability distribution of the number of groups formed in the initial hand of 14 cards. The results show that approximately 50% of the hands contain no groups, while about 40% of the hands contain exactly one group. Only a small fraction of hands contain two groups, and very few contain more than two groups.

The bottom panel shows the probability distribution of the number of sequences formed in the initial hand. The data indicates that around 80% of the initial hands contain no sequences, while about 20% contain exactly one sequence. It is very rare to have more than one sequence in the initial hand.

These distributions align with the combinatorial expectations discussed earlier, where forming valid groups and sequences in the initial hand is a relatively infrequent event, but it sets the stage for the game dynamics as players draw and discard cards to optimize their hands.

Average Number of Players Left in Game Over Turns

This figure shows the average number of players remaining in the game over the course of the turns. The plot starts with 4 players and shows a gradual decrease in the number of players as the game progresses.

  • The first point at turn 0, marked with an arrow, indicates the starting point where there are 4 players left in the game.
  • The second point around turn 120, marked with an arrow, indicates the average turn number where there are 3 players left in the game.
  • The third point is asymptotic with the number of turns and appears only at ~5000 turns on average, marked with an arrow, indicates the average turn number where there are 2 players left in the game, right before the game ends.

The horizontal dashed line at y=2 marks the threshold where the game ends (fewer than 2 players left). The vertical dashed lines show the exact turn numbers corresponding to the points marked with arrows. This plot provides a clear visualization of how quickly players are eliminated from the game on average, highlighting the game’s progression dynamics. The points, arrows, and lines make it easy to see the specific turn numbers where significant changes in the number of players occur.

Conclusion

We have explored some of the fundamental dynamics and boundaries of Rummikub through a simple Python simulation. By simulating games where moves are made randomly (absence of strategy and decision making), we were able to delve into the probabilistic and combinatorial aspects of the game, providing insights into its intrinsic complexity and behavior.

Key Takeaways:

  1. Custom Rules and Simulation Framework:
  • We defined a custom version of Rummikub involving 4 players and a series of turns where each player either takes a passed card or draws a new one, aiming to form valid groups and sequences.
  • The simulation framework included classes for managing cards, the stack, sequences, groups, and players, with methods for drawing, reinserting, and validating cards.

2. Probabilistic and Combinatorial Analysis:

  • We calculated the probability of drawing specific cards and forming valid groups and sequences within a hand of 14 cards.
  • The expected number of groups from an initial hand was around 1.2, while the probability of forming sequences showed that having no sequences is common, but there’s still a significant chance of having one sequence.

3. Simulation Results:

  • The distribution and cumulative distribution of the number of turns to game end highlighted that most games tend to conclude around 100 turns, with 90% of the games ending within 500 turns.
  • The probability distributions of the number of groups and sequences in the initial hand revealed that it is common to have at least one group and no sequences, setting the stage for the game dynamics.
  • The analysis of the average number of players left in the game over turns provided a clear visualization of how quickly players are eliminated from the game on average, with significant changes occurring at specific turn numbers.

Implications:

This analysis demonstrates that Rummikub, even when played randomly, exhibits complex combinatorial dynamics. Understanding these dynamics can help players strategize better by recognizing the likelihood of forming groups and sequences and anticipating the game’s progression. The insights gained from the simulation can also be useful for developing AI players (Classic Reinforcement Learning Project, Maybe for my next post!) that mimic human strategies by leveraging probabilistic and combinatorial principles.

By combining theoretical calculations with practical simulations, we have provided a detailed exploration of Rummikub’s gameplay mechanics. This approach not only enhances our understanding of the game but also showcases the power of computational simulations in analyzing complex systems.

Future Work:

Further research could involve incorporating more sophisticated strategies into the simulation, analyzing the impact of different rule variations, and exploring the game dynamics with a larger number of players. Additionally, extending the analysis to include the role of jokers and other special tiles could provide a more comprehensive understanding of Rummikub.

Through this blog, I hope to have shed light on the fascinating world of Rummikub and inspired others to explore virtually any game's depths using computational tools and probabilistic analysis.

--

--

Thomas Konstantinovsky
The Pythoneers

A Data Scientist fascinated with uncovering the mysterious of the world