Simulating Play: The Math Of “Left Center Right”

This is the second installment of a series on simulating the dice game “Left Center Right” with Python. By no means do you need to read the first part to understand this one, but if you want it’s linked here.

In the previous installment we looked at the different types of throws possible in the game (i.e., what combinations of three dice being thrown are more likely than others) and found that the game has a natural tendency to decay. For context, the game requires players to pass coins to each other, keep coins, or put them in a central pot on their turn. Once all coins are possessed by exactly one player and the central pot, they are declared winner. The catch is that coins never leave the central pot, and thus the game tends toward decay.

The question that follows is: Do games decay at different rates if the number of dice is changed? And if so, how long is the average game given the number of dice?

In order to answer that, we need to simulate play!

Shutterstock ©

Player And Game Classes

The first bit is some lovely OOP where we create Player and Game classes. Each instance of the former represents an individual playing the game, and the latter just tracks the rounds elapsed and coins in central pot.

The Player Class

The Player class does a few main things: It rolls the dice, it passes coins, and it stores gamestate information on that given Player. It looks like this (and please forgive the horribly-mangled Medium presentation sans-indents — full notebook here):

class Player:‘’’Represents a human player in the LCR game. — -player_numID number. Should be unique from other Players.coinsAmount of coins a Player has at any time. Defaults to 0.to_leftPlayer who is to their left. Default None.to_rightPlayer who is to their right. Default None.gameObject of class Game. Makes Player able to alter centralpot of coins. Default None.‘’’def __init__(self, player_num, coins = 0, to_left = None,to_right = None, game = None):self.player_num = int(player_num)self.coins = int(coins)self.to_left = to_leftself.to_right = = gamedef roll(self, num_dice):‘’’Rolls n dice and returns the output as a list of n strings. Wrapper.‘’’roll = roll_LCR(num_dice)return rolldef pass_coins(self, roll):‘’’Takes in a roll as list from roll() function. Passes coins toappropriate players and pot. Subtracts lost coins from self.‘’’self.coins -= len([x for x in roll if x != ‘O’])self.to_right.coins += len([x for x in roll if x == ‘R’])self.to_left.coins += len([x for x in roll if x == ‘L’]) += len([x for x in roll if x == ‘C’])

Now here’s the key thing: The Player objects will only function if they are instantiated in relation to one another. See the last three lines of pass_coins()? These do something lovely that Python classes are capable of, and that I only learned about for this project: Whole objects of a given class can be passed as attributes to other objects! By passing in the neighboring players as to_right and to_left, we create a rudimentary relational network between them all.

A clearer example looks like this:

john = Person(age = 15, sibling = None)bibby = Person(age = 10, sibling = John)bibby.sibling.age = 30john.age30

See how we doubled John’s age without ever going into him and changing it, per se? That’s how we manage coin passing in-game. With that established, let’s look at the Game class.

The Game Class

class Game:‘’’Represents gamestate for LCR. Tracks central pot of coins and how longthe players have been playing for. — -rounds_playedNumber of game rounds that have been played. Defaults to 0.central_potNumber of coins in the central pot. Coins never leave this pot,i. e. this value should never decrease. Defaults to 0.‘’’def __init__(self, rounds_played = 0, central_pot = 0):self.rounds_played = rounds_playedself.central_pot = central_pot

Again, a fairly simple class that tracks two things. This could be replaced with two variables in all honesty, one for each attribute in question, but I’m building it classwise for now in case I do decide on adding functions or making Game objects more versatile. With our classes defined, we can simulate play!

Simulating LCR With Python

We first need a helper function to keep our final code cleaner:

def LCR_take_turn(player, dice_max = 3):‘’’Represents one player taking a turn in LCR. — -playerPlayer object.dice_maxMax number of dice in the current game. Players roll either thisnumber of dice, or a number of dice equivalent to their amount ofcoins (since they can only pass the coins they have). Defaults to 3.‘’’if player.coins == 0:returnelif player.coins < dice_max:roll = player.roll(num_dice = player.coins)player.pass_coins(roll)else:roll = player.roll(num_dice = dice_max)player.pass_coins(roll)

This function rolls as many dice as the player has coins, or it rolls three dice, whichever is lower. This will be called constantly during play, which looks like this:

def LCR_simulation(num_dice = 3):# Create gamegame = Game()# Create playersemi = Player(player_num = 1, coins = 3, to_left = None,to_right = None, game = game)steven = Player(player_num = 2, coins = 3, to_left = emi,to_right = None, game = game)matt = Player(player_num = 3, coins = 3, to_left = steven,to_right = emi, game = game)players = [emi, steven, matt]# Set up relationshipsemi.to_left, emi.to_right = matt, stevensteven.to_right = matt# Play game until only one player has coinsplayer_coins = [emi.coins, steven.coins, matt.coins]while (len([x for x in player_coins if x]) != 1):for i in players:LCR_take_turn(i, dice_max = num_dice)game.rounds_played += 1# Sort players by coin countsorted_players = sorted(players, key = operator.attrgetter(‘coins’),reverse=True)# Find winnerwinner = sorted_players[0].player_num# Save winner’s coinsfor i in players:if i.player_num == winner:winner_coins = i.coins# Save all end game informationresults_dict = {“Rounds Played”: game.rounds_played,“Coins In Central Pot”: game.central_pot,“Winner”: winner,“Winner Coins”: winner_coins}return results_dict

Now I know this is a lot, and Medium isn’t particularly great at displaying code, so I implore you to read through on the notebook as well. If you read through the logic this code is fairly self-explanatory, but there’s one piece I would really like to highlight before we wrap things up here — To determine when the game is over, we have to constantly check if only one player has any coins. We do this with a while loop:

while (len([x for x in player_coins if x]) != 1):

Inside this loop above we hold the entirety of the game’s “playing,” per se. But what does it translate to? Well first of all we have the variable player_coins. This is simply a list of [emi.coins, steven.coins, matt.coins], which could also be visualized as [2, 3, 4], for example.

With the list comprehension [x for x in player_coins if x] we’re really saying “Each item in the list if the item evaluates to True.” In our integer-based case, this really really means “Give me everything that isn’t a 0.” If, for example, one player has all the coins now, our list of player_coins could look like [0, 7, 0], in which case this comprehension evaluates to [7].

We then check the length of this list and, if it’s 1, the while loop breaks and no longer runs. In English the whole thing can be said as “So long as more than one player has coins, keep running the game.”

To me, this is a perfect example of code that is 1) extremely efficient space-wise in expressing a complex idea and 2) written just logic-focused enough as to be somewhat difficult to explain. In this case I’m obviously prioritizing space, but it’s worth considering going forward how much effort it took to elaborate what it really meant.

In Conclusion

So, we now have a Player class, a Game class, and a LCR_simulation() function that encapsulates everything. In the next (and likely final) installment of this series, I’ll be going over the results of simulating games of LCR at different dice amounts, and visualizing their different rates of decay.

As always I can be found on Twitter @zych_steven or here via comment. Happy coding!

Data Scientist / Python Programmer / NLP Geek