This is a simulation of movements around the board in the game of Monopoly. Ultimately we'll produce a table of frequencies of the squares that you could expect to land on.
The simulation takes into account die rolls, cards (chance and community chest), and going to jail.
Going to jail can happen in any of three different ways:
- landing on the "go to jail" square,
- receiving a "go to jail" card, or
- rolling doubles three times in a row.
The one weak assumption is regarding time spent in jail. When in jail players can exit in one of three ways:
- using a "get out of jail card",
- paying a fine before their next role, or
- rolling a double and moving by that number.
This simulation assumes that a player will exit straight away i.e. paying the fine straight away and moving on their next turn. In practice it's better to exit straight away early in the game (when you're trying to buy property) but to remain in jail towards the end of the game (when you're trying to avoid opponents' properties but still earn "rent" from your opponents landing on your properties).
We're also going to explore the difference betwee running one simulation many times - this should approach the long-run probabilities derived by a Markov chain - vs running many simulations a small number of times which should more accurately reflect the actual probabilities given that each player will have around 30 turns in total.
Dataframes is used to store the results in a table and Gadfly is used for the plots.
using DataFrames, Gadfly
There are 40 "squares" on the board. The naming is as follows:
See list here. There are 16 cards in total and 2 cards that influence board position:
See list here. There are 16 cards in total and 10 cards that influence board position:
# The board: a list of the names of the 40 squares
board = split("GO A1 CC1 A2 T1 R1 B1 CH1 B2 B3
JAIL C1 U1 C2 C3 R2 D1 CC2 D2 D3
FP E1 CH2 E2 E3 R3 F1 F2 U2 F3
G2J G1 G2 CC3 G3 R4 CH3 H1 T2 H2")
# The deck of 16 community chest cards.
CC = ["GO", "JAIL", fill("£", 14)...]
# The deck of 16 chance cards.
CH = ["GO", "JAIL", "C1", "E3", "H2", "R1", "R", "R", "U", "-3", fill("£", 6)...];
The frequency chart will look better if the board colours are recognisable. This boardcolours
function maps the board squares to colours.
function boardcolours(str)
if str == "GO" return colorant"khaki"
elseif str == "JAIL" return colorant"khaki"
elseif str == "FP" return colorant"khaki"
elseif str == "G2J" return colorant"khaki"
elseif str[1:2] == "CC" return colorant"grey50"
elseif str[1:2] == "CH" return colorant"grey50"
elseif str[1] == 'R' return colorant"lightgrey"
elseif str[1] == 'T' return colorant"black"
elseif str[1] == 'U' return colorant"lightgrey"
elseif str[1] == 'A' return colorant"saddlebrown"
elseif str[1] == 'B' return colorant"lightblue"
elseif str[1] == 'C' return colorant"deeppink"
elseif str[1] == 'D' return colorant"orange"
elseif str[1] == 'E' return colorant"red"
elseif str[1] == 'F' return colorant"yellow"
elseif str[1] == 'G' return colorant"green"
elseif str[1] == 'H' return colorant"blue"
else return colorant"black"
end
end;
uk_names = [
"Go", "Old Kent Road", "Community Chest 1", "Whitechapel Road", "Income Tax",
"Kings Cross Station", "The Angel Islington", "Chance 1", "Euston Road", "Pentonville Road",
"Jail", "Pall Mall", "Electric Company", "Whitehall", "Northumberland Avenue",
"Marylebone Station", "Bow Street", "Community Chest 2", "Marlborough Street", "Vine Street",
"Free Parking", "Strand", "Chance 2", "Fleet Street", "Trafalgar Square",
"Fenchurch Street Station", "Leicester Square", "Coventry Street", "Water Works", "Piccadilly",
"Go to Jail", "Regent Street", "Oxford Street", "Community Chest 3", "Bond Street",
"Liverpool Street Station", "Chance 3", "Park Lane", "Super Tax", "Mayfair"]
us_names = [
"Go", "Mediterranean Avenue", "Community Chest 1", "Baltic Avenue", "Income Tax",
"Reading Railroad", "Oriental Avenue", "Chance 1", "Vermont Avenue", "Connecticut Avenue",
"Jail", "St. Charles Place", "Electric Company", "States Avenue", "Virginia Avenue",
"Pennsylvania Railroad", "St James Place", "Community Chest 2", "Tennessee Avenue", "New York Avenue",
"Free Parking", "Kentucky Avenue", "Chance 2", "Indiana Avenue", "Illinois Avenue",
"B&O Railroad", "Atlantic Avenue", "Ventnor Avenue", "Water Works", "Marvin Gardens",
"Go to Jail", "Pacific Avenue", "No. Carolina Avenue", "Community Chest 3", "Pennsylvania Avenue",
"Short Line Railroad", "Chance 3", "Park Place", "Super Tax", "Boardwalk"];
# Go to destination square, which can be either a number (board position) or a name. Update 'here'.
goto(square::Int, board=board) = mod1(square, length(board)) # Modulo, as can keep going round
goto(square::String, board=board) = findfirst(board, square) # Board location of named square;
Card is selected from the top of the deck and replaced at the bottom. There are 3 types of movement cards:
# Take the top card from deck and do what it says.
function take_card(here, deck, board=board)
card = pop!(deck) # Lift card from top of deck
unshift!(deck, card) # Put card back on bottom of deck
if card == "R" || card == "U" # Advance to next railroad or utility
while string(board[here][1]) != card # Keep stepping until you get there
here = goto(here + 1)
end
elseif card == "-3" # Go back 3 spaces
here = goto(here - 3)
elseif card == "£" # Card is about money, not about movement
nothing
else # Go to destination named on card
here = goto(card)
end
here
end;
# Simulate given number of steps of monopoly game, yielding the name of the current square after each step.
function monopoly(steps, board=board, CC=CC, CH=CH)
# Prep for game
results = zeros(Int, length(board)) # Initialise zero vector - used for counts
shuffle!(CC) # Shuffle the Community Chest pack
shuffle!(CH) # Shuffle the Chance pack
here = 1 # Start at position 1 i.e. "GO"
doubles = 0 # Initialise counter of doubles - 3 doubles in a row -> "GO TO JAIL"
# Play game
for i = 1:steps
d1, d2 = rand(1:6), rand(1:6) # Two dice rolls - uniformly selected from integers 1 to 6
here = goto(here + d1 + d2) # Go to new position i.e. current position + dice roll
d1 == d2 ? doubles = (doubles + 1) : doubles = 0 # If double is rolled add to double counter
if doubles == 3 || board[here] == "G2J" # 3 doubles in a row or land on "GO TO JAIL"
here = goto("JAIL")
elseif board[here][1:2] == "CC" # If land on Community Chest take a card and move if needed
here = take_card(here, CC)
elseif board[here][1:2] == "CH" # If land on Chance take a card and move if needed
here = take_card(here, CH)
end
results[here] += 1 # Add to results for the updated position
end
# Return the tallies of each board square
return results
end;
The first simulation plays 1 million turns of the game defined above. The output frequencies should be the same as the long run probabilities calculated by a Markov chain. This website has calculated those probabilities and even includes a full transition matrix. I'll compare below the probabilities derived here from this single game simulation with the probabilites derived by the Markov chain. The only difference with the game setup here vs that page is that here there is no distinction between "just visiting jail" and "in jail".
monopoly(100) # Warm up
# The output vector is a fixed size - i.e. does not depend on the number of simulations - so we can run quite a big number.
# Julia is great at this.
@time results = monopoly(10_000_000);
df = DataFrame(board = board, uk_names = uk_names, us_names = us_names, boardcolour = boardcolours.(board),
sim1_prob = results ./ sum(results) * 100);
I think, in this case, the vertical bars are easier to read than the horizontal.
1) Vertical bars with UK names:
set_default_plot_size(25cm, 25cm/golden)
plot(df, x=:uk_names, y=:sim1_prob, color=:boardcolour, Geom.bar,
Guide.xlabel(nothing), Guide.ylabel("Frequency"), Guide.yticks(ticks=[0:7;]),
Scale.y_continuous(labels=y -> @sprintf("%0.2f%%", y)),
Theme(bar_spacing=0.7mm))
2) Horizontal bars with US names:
set_default_plot_size(25cm, 20cm)
plot(df, x=:sim1_prob, y=:us_names, color=:boardcolour, Geom.bar(orientation=:horizontal),
Guide.ylabel(nothing), Guide.xlabel("Frequency"), Guide.xticks(ticks=[0:7;]),
Scale.x_continuous(labels=x -> @sprintf("%0.2f%%", x)),
Scale.y_discrete(order=collect(40:-1:1)),
Theme(bar_spacing = 0.5mm))
How close are the probabilities calculated from the simulation to the probabilities calculated by the transition matrix? The plot below shows they are a very close match - all are a match to the first decimal point.
# Markov chain probabilities copied from http://www.tkcs-collins.com/truman/monopoly/monopoly.shtml
df[:mc_prob] = [3.0961,2.1314,1.8849,2.1624,2.3285,2.9631,2.2621,0.865,2.321,2.3003,6.2194,2.7017,2.604,2.3721,2.4649,2.92,2.7924,2.5945,2.9356,3.0852,2.8836,2.8358,1.048,2.7357,3.1858,3.0659,2.7072,2.6789,2.8074,2.5861,0,2.6774,2.6252,2.3661,2.5006,2.4326,0.8669,2.1864,2.1799,2.626]
df[:sim_vs_mc] = df[:sim1_prob] .- df[:mc_prob];
set_default_plot_size(25cm, 25cm/golden)
plot(sort(df, cols=:sim_vs_mc, rev=true), x=:uk_names, y=:sim_vs_mc, #color=:boardcolour,
Geom.bar, Guide.xlabel(nothing), Guide.ylabel("Simulation vs Markov Chain", orientation=:vertical),
Guide.yticks(ticks=[-0.05:0.01:0.05;]), Scale.y_continuous(labels=y -> @sprintf("%0.2f ppt", y)), Theme(bar_spacing=1mm))
Let's find out how different the probabilities will be if, instead of playing one long game (1 million turns in simulation 1 above) we play many games starting again at "Go". The idea being that if a full game takes only - say - 30 turns the probabilities of landing on each square will be different to the long run Markov chain probabilities.
This is really for interest as I don't think these probabilities are as relevant for Monopoly strategies. What is probably most important is - towards the end of the game, when everyone's pieces are randomly spread around the board, what is the probability of you landing on someone elses hotels and what is the probability of everyone else landing on yours.
First we need a function to collect the results of many games.
function monopolies(games, steps, board=board, CC=CC, CH=CH)
# Prep for games
results = zeros(Int, length(board)) # Initialise zero vector - used for counts
# Play games
for i = 1:games
results .+= monopoly(steps)
end
return results
end;
monopolies(100, 30) # Warm up
# Sim 2
@time results = monopolies(400_000, 30);
df[:sim2_prob] = results ./ sum(results) * 100
plot(df, x=:uk_names, y=:sim2_prob, color=:boardcolour, Geom.bar,
Guide.xlabel(nothing), Guide.ylabel("Frequency"),
Scale.y_continuous(labels=x -> @sprintf("%0.2f%%", x)),
Theme(bar_spacing = 1mm))
There isn't a material difference. And the small differences are intuitive - e.g. it is now less likely to land on the first square after Go i.e. Old Kent Road and more likely to land on the squares within one roll of Go (e.g. the light blue streets Euston Road, Angel Islington and Pentonville Road).
df[:sim2_vs_sim1] = df[:sim2_prob] .- df[:sim1_prob];
plot(sort(df, cols=:sim2_vs_sim1, rev=true), x=:uk_names, y=:sim2_vs_sim1, #color=:boardcolour,
Geom.bar, Guide.xlabel(nothing), Guide.ylabel("Simulation 2 vs Simulation 1", orientation=:vertical),
Guide.yticks(ticks=[-0.4:0.1:0.4;]), Scale.y_continuous(labels=y -> @sprintf("%0.2f ppt", y)),
Theme(bar_spacing=1mm))