In [1]:

```
import functools
import random
import statistics
import math
import plotly.express as px
```

use a low number (2,000 to 10,000) for speed while writing our code and iterating, then a high number (25,000 to 100,000+) when validating our results

In [2]:

```
simulations = 5000
```

we've created a relatively simple game, with 3 combo sets and 2 instant-win prizes

you could easily extend this by adding more combo sets

In [3]:

```
# here's where we define the piece names, the prizes for each set, and the probability (ratio) of picking each piece
# Monopoly uses street names in Atlantic City, we'll use street names from other cities
combosets = {
# define our combos -- piece names, prize amount, and piece ratio
# ratios are universally relative... a piece with ratio 6 will be twice as common as a ratio of 3,
# even across different groups
# groups should be set up with one rare piece, the higher the value of the group, the rarer the final piece should be
'New York' : {
'prize' : 50,
'pieces' : {
'Wall Street' : {'ratio' : 12 } ,
'5th Avenue' : {'ratio' : 12 } ,
'Broadway' : {'ratio' : 6 }
}
} ,
'Las Vegas' : {
'prize' : 100,
'pieces' : {
'Tropicana Ave' : {'ratio' : 8 } ,
'Fremont St' : {'ratio' : 8 } ,
'Las Vegas Blvd' : {'ratio' : 4 }
}
} ,
'Los Angeles' : {
'prize' : 200,
'pieces' : {
'Pacific Coast Hwy' : {'ratio' : 12 } ,
'Sunset Blvd' : {'ratio' : 12 } ,
'Rodeo Drive' : {'ratio' : 3 }
}
} ,
# offer instant prizes for specific pieces that aren't part of a combo
'InstantChicago' : {
'prize' : 20,
'pieces' : {
'Michigan Ave' : {'ratio' : 6 }
}
} ,
'InstantNewOrleans' : {
'prize' : 40,
'pieces' : {
'Bourbon Street' : {'ratio' : 3 }
}
}
}
# you can also offer a prize to avoid discouragement when players get multiples of the same piece
# for example, 3 identical pieces = $25
duplicate_award = { 'duplicates' : 3 , 'prize' : 25 }
# entire board grand prize - to the first person to cover the entire board, i.e. collect at least 1 of every piece
# this should be guaranteed, i.e. given away via drawing if not won - but, we will try to set p(at least 1 winner) > 95%
grand_prize = 50000
# we also designated a prize for anyone who completed the board _after_ the grand prize was claimed
# completing the board is a difficult accomplishment, and only a handful of players should be able to do it
not_grand_prize = 2500
```

The typical McDonald's game gives away its grand prize (and all large prizes) when a player collects 1 *extremely* rare game piece. Two potential problems, when running a similar game on a smaller scale, is that the key game piece is just as likely to be found on Day 1 as the final day, which is an issue because the players' motivation is likely to be lost once the grand prize has been claimed. The other potential problem is the high likelihood that the key game piece is never collected. Especially when running the game for the first time, the total supply of pieces should exceed the expected number needed by about 20-30% in case the game is more popular than expected. This means that 20%+ of all pieces may never be awarded, and the super-rare piece could easily be in that group.

An alternative solution is to award the grand prize to the first player who completes the entire board, in other words, collects at least one of every game piece. By configuring the ratio of pieces (the point of this notebook), we can create a game with a likelihood of a grand prize winner at 95% (or higher, as desired), and be nearly guaranteed that the grand prize won't be claimed until right near the end of the promotion period, keeping motivation and excitement high as long as possible.

i.e. in the scenario below, we expect 250 players will earn 5 pieces (exactly 5, not "at least" 5)

**This is the trickiest part of the model**, because it's a guess -- we don't know exactly how popular the game will be or how many players will participate, and we can't do a "practice run." It is essential to review historical gaming data to make a highly educated guess backed by reliable data.

If our estimates are too low, we will give out more prizes than we expected, exceeding budget. If our estimates are too low, fewer players will win, players will be frustrated, and it is less likely the grand prize will be won during the promo period, necessitating a "second-chance" drawing or similar.

In [4]:

```
# a possible distribution for a 7-day game with max. 3 pieces earned per day
# example: we expect 1000 players to earn 1 piece, expect 600 players to earn 2 pieces, etc.
expected_players = {
1 : 1000 , 2 : 600 , 3 : 400 , 4 : 300 , 5 : 250 ,
6 : 200 , 7 : 180 , 8 : 160 , 9 : 140 , 10 : 120 ,
11 : 100 , 12 : 90 , 13 : 80 , 14 : 70 , 15 : 60 ,
16 : 50 , 17 : 40 , 18 : 30 , 19 : 15 , 20 : 25 ,
21 : 40 , 22 : 0 , 23 : 0 , 24 : 0 , 25 : 0 ,
26 : 0 , 27 : 0 , 28 : 0 , 29 : 0 , 30 : 0
}
```

`for`

loops would be more verbose but probably easier to follow. In other languages, `reduce`

(or `fold`

) and `map`

functions are a better solution.¶In [5]:

```
# for each city, get a list of streets
# `.items()` iterates through a dictionary and produces a (key & value) tuple for each
combos = { k : list(v["pieces"].keys()) for (k, v) in combosets.items() }
combos
```

Out[5]:

In [6]:

```
# list of all pieces
# this is a common way to "flatten" multiple lists, i.e. combine a list of lists into one signle list
# in this case our list of lists is combos.values()
all_pieces = [street for streets in combos.values() for street in streets] # list(piece_map.keys())
all_pieces
```

Out[6]:

In [7]:

```
# list of all pieces that are eligible for a "duplicates" prize (basically everything that isn't an instant prize)
# same as above, but first we filter `combos` to take out the Instant prizes
duplicate_eligible = [street for streets in [v for (k,v) in combos.items() if "instant" not in k.lower()] for street in streets]
duplicate_eligible
```

Out[7]:

In [8]:

```
# this is the dict we will feed to Python's `random.choices` function to simulate players picking random game pieces
# `keys()` and `values()` will be lists of every street and its weighting ratios
ratios = { street : v["pieces"][street]["ratio"] for v in combosets.values() for street in v["pieces"] }
ratios
```

Out[8]:

In [9]:

```
sim_results = {}
all_random_pieces = []
```

In [10]:

```
# run the loop for every number of pieces possible to be earned
for pieces in [k for k in expected_players.keys() if (expected_players[k] > 0)]:
#
# first, we simulate picking random pieces
#
# ** this is the entire simulation !! **
#
# everything before this step is definition and setup
# everything after this step is just calculations to determine the prize values of the random pieces chosen here
#
# this game does not have any decisions to be made, nor options, nor any second steps (i.e. pass line in craps)
#
# players pick game pieces, those pieces earn prizes, that's it!
#
# python will make [pieces] number of random selections, weighted by 'ratios' - and we do it thousands of times
random_pieces = [ random.choices(
population=list(ratios.keys()), # population - list of possible values
weights=list(ratios.values()), # weights, i.e. ratios
k=pieces) # k = how many game pieces to pull - this will vary every loop
for _ in range(simulations) ] # simulations - should be thousands, minimum
#
# we now have a list (with length = simulations) of lists
#
# second step is calculating prize values for each individual simulation
#
# we will print output from these functions below... easier to understand each step when you can see the output
#
# count the number of pieces chosen within each combo group
piece_counts = [{k : [p.count(street) for street in v] for (k,v) in combos.items()} for p in random_pieces]
# number of successful combos completed * prize value
# the number of successful combos = the minimum number of pieces in a group (no complex math needed)
# instant prizes work just like combo prizes (essentially a combo of size = 1), don't need to do anything different
group_prizes = [{k : min(pc_dict[k]) * combosets[k]["prize"] for (k,v) in combos.items()} for pc_dict in piece_counts]
# number of completed duplicate (3-of-a-kind) game pieces * prize value
# all we need to do is integer(floor) division against the count of each game piece, and multiply that by the prize value
duplicate_prizes = [[duplicate_award["prize"] * (piece_list.count(street) // duplicate_award["duplicates"])
for street in duplicate_eligible] for piece_list in random_pieces]
# sum the prizes for each simulation & "zip" into a 2-value tuple for: (combo group prize total, duplicate prize total)
prizes = [(sum(a.values()),sum(b)) for (a,b) in list(zip(group_prizes, duplicate_prizes))]
# boolean: did the player cover the entire board?
covered_entire_board = [all(item in piece_list for item in all_pieces) for piece_list in random_pieces]
#
# we will print results of first two steps to help understand what each object does
#
#
# we will display limited samples of the simulations in order to follow along
# printing everything would blow up our console and take a loooooong time
if pieces == 8 or pieces == 20:
print(f"------- first 10 simulations with {pieces} pieces ({simulations} simulations total) ------------------------------------\n")
print("pieces selected:\n", random_pieces[:10], "\n")
print("count of pieces by group:\n", piece_counts[:10], "\n")
print("prizes for group completion:\n", group_prizes[:10], "\n")
print("prizes for 3-of-a-kind:\n", duplicate_prizes[:10], "\n")
print("total prize values:\n", prizes[:10], "\n")
print("was board completed?:\n", covered_entire_board[:10], "\n")
#
# last step is to get the average of all the individual simulation results, to calculate expected costs
#
#
# finally, let's summarize all the individual trials we've run
# average prizes won per player
avg_prize = statistics.mean([a + b for (a,b) in prizes])
# expected total cost: average prize * # number of players
expected_cost = avg_prize * expected_players[pieces]
# probability of a player covering the entire board
prob_covered = statistics.mean([1.0 if ceb else 0.0 for ceb in covered_entire_board])
# we add the results of the summation to our `sim_results` object
# we are running this loop once for every number of pieces earned, therefore that dict will have one entry for each loop
sim_results[pieces] = { 'avg_prize' : avg_prize ,
'expected_players' : expected_players[pieces],
'expected_cost' : expected_cost ,
'prob_covered' : prob_covered ,
'sim_count' : simulations }
# we will use this at the end to check total randomness
all_random_pieces.extend(random_pieces)
```

In [11]:

```
# here's what that big loop produces
# the total expected prizes awarded is the sum of all the `expected cost fields` + the grand prize value
# the probability of at least one grand prize winner is the inverse of not having a winner, which we can calculate (below)
# using the `prob_covered` and `expected_players` fields
for p in sim_results:
print(p, sim_results[p])
```

In [12]:

```
# calculate the number of players likely to collect the "second-place" prize for completing the board, and its expected cost
other_board_finishers = (sum([sim['expected_players'] * sim['prob_covered'] for sim in sim_results.values()]) - 1)
cost_other_board_finishers = other_board_finishers * not_grand_prize
```

The total expected cost is just the sum of all the above `expected_cost`

plus the grand prize and other board-completion prizes

The formula for determining the probability of having at least 1 grand prize winner is a little more complex:

$$\Large {1-}\large \prod _{i=1}^{n} {{(1 - P(covered))}^{expected\_players}}_i$$...where n is the max number of entries a player can earn (21 in our example)

The goal should be to have it at right around 95-97%. Why not 100%? Keep in mind, the easier it is to win, the more likely the grand prize will be won earlier in the promotion. In a 7-day promo, ideally the grand prize would be won no earlier than the 6th or 7th day. In a 21-day promotion, ideally not until at least the 19th day. If the piece ratios are tweaked too much to guarantee a winner, it's also likely it will happen too early.

In [13]:

```
total_expected_cost = sum([sim['expected_cost'] for sim in sim_results.values()]) + grand_prize + cost_other_board_finishers
```

In [14]:

```
# math.prod requires python 3.8+
# still an easy formula to represent and calculate
prob_at_least_one_winner = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in sim_results.values()])
```

In [15]:

```
total_expected_cost
```

Out[15]:

In [16]:

```
prob_at_least_one_winner
```

Out[16]:

In [17]:

```
other_board_finishers
```

Out[17]:

In [18]:

```
# the total number of game pieces you need to print should be the total expected from all player counts,
# plus an extra safety margin if the game is more popular
game_pieces_needed = sum([ k * v for (k,v) in expected_players.items() ]) * 1.25
game_pieces_needed
```

Out[18]:

In [19]:

```
# knowing the total number of pieces is nice, but what we really need to know is HOW MANY of each to print
# our ratios need to be consistent with the model to keep the probabilities the same
ratios_sum = sum([v for v in ratios.values()])
piece_counts = {k : int(v * (game_pieces_needed / ratios_sum)) for (k,v) in ratios.items()}
piece_counts
```

Out[19]:

We have the values we need, the next step is to tweak the input parameters and re-run. We want to see how our changes affect the final results, in an attempt to get closer to the target budget, or to produce multiple configurations to choose from. We should also adjust the expected number of players, especially those collecting the highest number of pieces. Since these players inidividually collect the most prizes and are most likely to win the grand prize, sometimes small changes can affect the results quite a bit. The promotion might be more exciting than you expect, and you want to be prepared if more players become obsessed with earning the maximum amount of pieces!

In [20]:

```
prob_day7 = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in list(sim_results.values())[:21] ])
prob_day6 = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in list(sim_results.values())[:18] ])
prob_day5 = 1 - math.prod([(1 - sim['prob_covered']) ** sim['expected_players'] for sim in list(sim_results.values())[:15] ])
(prob_day7, prob_day6, prob_day5)
```

Out[20]:

Computers can't generate true random values on their own, they can only "simulate" randomness. As long as the generated random values are uniform (equally likely), that's sufficient for a simulation like this one. You want to make sure, then, that the randomness algorithm is good at generating uniformity. Fortunately Python's default random generator uses the `Mersenne-Twister`

algorithm which is very good at this. Be careful, however, if you translate this code to JavaScript, other languages, or attempt to do any randomness in Microsoft Excel. All of these have packages/add-ins to use Mersenne-Twister but they aren't part of the default language/application.

Below, we're going to test our simulation's uniformity

In [21]:

```
# all_random_pieces is a list of lists, we need to flatten to a single-list
# all_selected_pieces is a list of every random piece selection made by all the runs of all the simulations
all_selected_pieces = [street for streets in all_random_pieces for street in streets]
len(all_selected_pieces)
```

Out[21]:

we'll comment it out, but you can un-comment it of course

In [22]:

```
# restrict our histogram to the first 5000 pieces -- note that if you run this yourself you should eliminate this restriction
# the benefit is a reduction in this file's size (for posting online)
fig = px.histogram(x=all_selected_pieces[:5000],
category_orders={"x":all_pieces},
labels={'x':'street name', 'y':'pieces'})
fig.show()
```