In [1]:
import functools
import random
import statistics
import math
import plotly.express as px

set the number of simulations to run (higher = more accuracy & confidence, but slower)

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

prep work: here we will define the parameters of our game

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

a note about the game:

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.

need to provide an estimate of the number of players who will earn __ pieces

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 
}

no more user inputs / setup / config from here... everything below is formula / functions / methods

first, we create objects we'll need when running our simulations

re-arranging attributes only - no data being changed here

these objects will come in handy soon

note: The following code makes heavy use of nested Python list/dict comprehensions, which can be difficult to follow. We display the output for every conversion, it's more important to see the result (what each function is intended to accomplish) than to understand the exact syntax. 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]:
{'New York': ['Wall Street', '5th Avenue', 'Broadway'],
 'Las Vegas': ['Tropicana Ave', 'Fremont St', 'Las Vegas Blvd'],
 'Los Angeles': ['Pacific Coast Hwy', 'Sunset Blvd', 'Rodeo Drive'],
 'InstantChicago': ['Michigan Ave'],
 'InstantNewOrleans': ['Bourbon Street']}
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]:
['Wall Street',
 '5th Avenue',
 'Broadway',
 'Tropicana Ave',
 'Fremont St',
 'Las Vegas Blvd',
 'Pacific Coast Hwy',
 'Sunset Blvd',
 'Rodeo Drive',
 'Michigan Ave',
 'Bourbon Street']
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]:
['Wall Street',
 '5th Avenue',
 'Broadway',
 'Tropicana Ave',
 'Fremont St',
 'Las Vegas Blvd',
 'Pacific Coast Hwy',
 'Sunset Blvd',
 'Rodeo Drive']

more object creation, we need to make a map to simulate probabilities of picking random pieces, based on the ratios we set initially

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]:
{'Wall Street': 12,
 '5th Avenue': 12,
 'Broadway': 6,
 'Tropicana Ave': 8,
 'Fremont St': 8,
 'Las Vegas Blvd': 4,
 'Pacific Coast Hwy': 12,
 'Sunset Blvd': 12,
 'Rodeo Drive': 3,
 'Michigan Ave': 6,
 'Bourbon Street': 3}

now we are ready to run the simulation

We will simulate the experience of players who collect 1 piece, then 2 pieces, then 3 pieces, etc, through the entire 'expected_players' dict. We run each individual simulation simulations times (defined at the top)

In [9]:
sim_results = {}
all_random_pieces = []

see the note preceding the first step -- picking the random pieces is all that is being simulated

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)
------- first 10 simulations with 8 pieces (5000 simulations total) ------------------------------------

pieces selected:
 [['Fremont St', '5th Avenue', 'Broadway', 'Michigan Ave', 'Wall Street', 'Sunset Blvd', 'Fremont St', 'Tropicana Ave'], ['Las Vegas Blvd', 'Sunset Blvd', 'Broadway', 'Pacific Coast Hwy', 'Pacific Coast Hwy', 'Pacific Coast Hwy', 'Fremont St', 'Sunset Blvd'], ['Sunset Blvd', 'Bourbon Street', 'Pacific Coast Hwy', 'Fremont St', 'Broadway', '5th Avenue', 'Tropicana Ave', 'Michigan Ave'], ['Pacific Coast Hwy', 'Pacific Coast Hwy', 'Michigan Ave', 'Las Vegas Blvd', 'Sunset Blvd', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Pacific Coast Hwy'], ['Wall Street', 'Pacific Coast Hwy', 'Fremont St', '5th Avenue', 'Tropicana Ave', '5th Avenue', 'Broadway', 'Fremont St'], ['Tropicana Ave', 'Sunset Blvd', 'Broadway', 'Fremont St', 'Pacific Coast Hwy', 'Wall Street', 'Michigan Ave', 'Las Vegas Blvd'], ['Michigan Ave', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Tropicana Ave', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Wall Street', 'Tropicana Ave'], ['Wall Street', 'Wall Street', 'Sunset Blvd', 'Tropicana Ave', 'Sunset Blvd', 'Sunset Blvd', '5th Avenue', 'Michigan Ave'], ['Broadway', 'Sunset Blvd', '5th Avenue', 'Sunset Blvd', 'Pacific Coast Hwy', 'Wall Street', 'Fremont St', 'Michigan Ave'], ['Fremont St', 'Pacific Coast Hwy', 'Bourbon Street', 'Wall Street', 'Sunset Blvd', '5th Avenue', 'Wall Street', 'Wall Street']] 

count of pieces by group:
 [{'New York': [1, 1, 1], 'Las Vegas': [1, 2, 0], 'Los Angeles': [0, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [0, 0, 1], 'Las Vegas': [0, 1, 1], 'Los Angeles': [3, 2, 0], 'InstantChicago': [0], 'InstantNewOrleans': [0]}, {'New York': [0, 1, 1], 'Las Vegas': [1, 1, 0], 'Los Angeles': [1, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [1]}, {'New York': [0, 0, 0], 'Las Vegas': [0, 0, 2], 'Los Angeles': [4, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [1, 2, 1], 'Las Vegas': [1, 2, 0], 'Los Angeles': [1, 0, 0], 'InstantChicago': [0], 'InstantNewOrleans': [0]}, {'New York': [1, 0, 1], 'Las Vegas': [1, 1, 1], 'Los Angeles': [1, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [1, 0, 0], 'Las Vegas': [2, 0, 2], 'Los Angeles': [2, 0, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [2, 1, 0], 'Las Vegas': [1, 0, 0], 'Los Angeles': [0, 3, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [1, 1, 1], 'Las Vegas': [0, 1, 0], 'Los Angeles': [1, 2, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [3, 1, 0], 'Las Vegas': [0, 1, 0], 'Los Angeles': [1, 1, 0], 'InstantChicago': [0], 'InstantNewOrleans': [1]}] 

prizes for group completion:
 [{'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 40}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 40}] 

prizes for 3-of-a-kind:
 [[0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 25, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 25, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [0, 0, 0, 0, 0, 0, 0, 25, 0], [0, 0, 0, 0, 0, 0, 0, 0, 0], [25, 0, 0, 0, 0, 0, 0, 0, 0]] 

total prize values:
 [(70, 0), (0, 25), (60, 0), (20, 25), (50, 0), (120, 0), (20, 0), (20, 25), (70, 0), (40, 25)] 

was board completed?:
 [False, False, False, False, False, False, False, False, False, False] 

------- first 10 simulations with 20 pieces (5000 simulations total) ------------------------------------

pieces selected:
 [['Wall Street', 'Sunset Blvd', 'Sunset Blvd', 'Broadway', 'Rodeo Drive', 'Pacific Coast Hwy', 'Fremont St', '5th Avenue', 'Tropicana Ave', '5th Avenue', '5th Avenue', '5th Avenue', 'Michigan Ave', 'Pacific Coast Hwy', '5th Avenue', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Pacific Coast Hwy', 'Sunset Blvd', '5th Avenue'], ['Fremont St', 'Las Vegas Blvd', '5th Avenue', 'Michigan Ave', 'Wall Street', 'Tropicana Ave', 'Tropicana Ave', 'Broadway', 'Bourbon Street', '5th Avenue', 'Bourbon Street', 'Michigan Ave', 'Tropicana Ave', 'Rodeo Drive', 'Tropicana Ave', 'Wall Street', 'Broadway', '5th Avenue', 'Fremont St', 'Michigan Ave'], ['Pacific Coast Hwy', 'Sunset Blvd', 'Broadway', 'Michigan Ave', '5th Avenue', 'Pacific Coast Hwy', 'Pacific Coast Hwy', 'Las Vegas Blvd', '5th Avenue', 'Michigan Ave', 'Sunset Blvd', '5th Avenue', 'Sunset Blvd', 'Rodeo Drive', 'Broadway', 'Wall Street', 'Broadway', 'Broadway', 'Broadway', 'Wall Street'], ['Bourbon Street', 'Fremont St', 'Pacific Coast Hwy', 'Tropicana Ave', '5th Avenue', '5th Avenue', 'Sunset Blvd', 'Wall Street', 'Pacific Coast Hwy', 'Michigan Ave', 'Broadway', 'Las Vegas Blvd', 'Wall Street', 'Tropicana Ave', '5th Avenue', 'Pacific Coast Hwy', 'Sunset Blvd', 'Fremont St', 'Wall Street', 'Wall Street'], ['Broadway', 'Pacific Coast Hwy', 'Wall Street', 'Sunset Blvd', 'Pacific Coast Hwy', 'Pacific Coast Hwy', 'Michigan Ave', 'Pacific Coast Hwy', '5th Avenue', 'Fremont St', 'Tropicana Ave', 'Michigan Ave', 'Michigan Ave', 'Rodeo Drive', 'Sunset Blvd', 'Sunset Blvd', '5th Avenue', 'Michigan Ave', 'Fremont St', '5th Avenue'], ['Wall Street', '5th Avenue', 'Pacific Coast Hwy', 'Fremont St', 'Pacific Coast Hwy', '5th Avenue', 'Broadway', 'Tropicana Ave', 'Wall Street', '5th Avenue', 'Tropicana Ave', 'Michigan Ave', 'Broadway', 'Wall Street', 'Broadway', 'Broadway', 'Pacific Coast Hwy', 'Broadway', 'Bourbon Street', 'Michigan Ave'], ['Wall Street', 'Fremont St', 'Wall Street', 'Tropicana Ave', 'Sunset Blvd', 'Las Vegas Blvd', 'Sunset Blvd', 'Tropicana Ave', 'Wall Street', '5th Avenue', 'Pacific Coast Hwy', 'Fremont St', 'Broadway', 'Sunset Blvd', 'Michigan Ave', 'Wall Street', 'Sunset Blvd', 'Pacific Coast Hwy', '5th Avenue', 'Wall Street'], ['Sunset Blvd', 'Sunset Blvd', 'Tropicana Ave', 'Sunset Blvd', 'Tropicana Ave', 'Broadway', 'Michigan Ave', 'Sunset Blvd', '5th Avenue', 'Michigan Ave', '5th Avenue', 'Wall Street', '5th Avenue', 'Wall Street', 'Sunset Blvd', 'Sunset Blvd', 'Wall Street', '5th Avenue', 'Rodeo Drive', 'Broadway'], ['Fremont St', 'Wall Street', 'Tropicana Ave', 'Pacific Coast Hwy', 'Las Vegas Blvd', 'Pacific Coast Hwy', '5th Avenue', 'Bourbon Street', 'Fremont St', 'Bourbon Street', 'Sunset Blvd', 'Pacific Coast Hwy', '5th Avenue', 'Tropicana Ave', 'Wall Street', 'Pacific Coast Hwy', 'Pacific Coast Hwy', 'Tropicana Ave', 'Michigan Ave', 'Las Vegas Blvd'], ['Pacific Coast Hwy', 'Fremont St', 'Wall Street', 'Sunset Blvd', '5th Avenue', '5th Avenue', 'Wall Street', 'Broadway', 'Wall Street', 'Bourbon Street', 'Pacific Coast Hwy', 'Sunset Blvd', 'Sunset Blvd', '5th Avenue', 'Las Vegas Blvd', 'Pacific Coast Hwy', 'Fremont St', 'Wall Street', 'Wall Street', 'Wall Street']] 

count of pieces by group:
 [{'New York': [1, 6, 1], 'Las Vegas': [1, 1, 1], 'Los Angeles': [4, 3, 1], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [2, 3, 2], 'Las Vegas': [4, 2, 1], 'Los Angeles': [0, 0, 1], 'InstantChicago': [3], 'InstantNewOrleans': [2]}, {'New York': [2, 3, 5], 'Las Vegas': [0, 0, 1], 'Los Angeles': [3, 3, 1], 'InstantChicago': [2], 'InstantNewOrleans': [0]}, {'New York': [4, 3, 1], 'Las Vegas': [2, 2, 1], 'Los Angeles': [3, 2, 0], 'InstantChicago': [1], 'InstantNewOrleans': [1]}, {'New York': [1, 3, 1], 'Las Vegas': [1, 2, 0], 'Los Angeles': [4, 3, 1], 'InstantChicago': [4], 'InstantNewOrleans': [0]}, {'New York': [3, 3, 5], 'Las Vegas': [2, 1, 0], 'Los Angeles': [3, 0, 0], 'InstantChicago': [2], 'InstantNewOrleans': [1]}, {'New York': [5, 2, 1], 'Las Vegas': [2, 2, 1], 'Los Angeles': [2, 4, 0], 'InstantChicago': [1], 'InstantNewOrleans': [0]}, {'New York': [3, 4, 2], 'Las Vegas': [2, 0, 0], 'Los Angeles': [0, 6, 1], 'InstantChicago': [2], 'InstantNewOrleans': [0]}, {'New York': [2, 2, 0], 'Las Vegas': [3, 2, 2], 'Los Angeles': [5, 1, 0], 'InstantChicago': [1], 'InstantNewOrleans': [2]}, {'New York': [6, 3, 1], 'Las Vegas': [0, 2, 1], 'Los Angeles': [3, 3, 0], 'InstantChicago': [0], 'InstantNewOrleans': [1]}] 

prizes for group completion:
 [{'New York': 50, 'Las Vegas': 100, 'Los Angeles': 200, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 100, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 60, 'InstantNewOrleans': 80}, {'New York': 100, 'Las Vegas': 0, 'Los Angeles': 200, 'InstantChicago': 40, 'InstantNewOrleans': 0}, {'New York': 50, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 40}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 200, 'InstantChicago': 80, 'InstantNewOrleans': 0}, {'New York': 150, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 40, 'InstantNewOrleans': 40}, {'New York': 50, 'Las Vegas': 100, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 0}, {'New York': 100, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 40, 'InstantNewOrleans': 0}, {'New York': 0, 'Las Vegas': 200, 'Los Angeles': 0, 'InstantChicago': 20, 'InstantNewOrleans': 80}, {'New York': 50, 'Las Vegas': 0, 'Los Angeles': 0, 'InstantChicago': 0, 'InstantNewOrleans': 40}] 

prizes for 3-of-a-kind:
 [[0, 50, 0, 0, 0, 0, 25, 25, 0], [0, 25, 0, 25, 0, 0, 0, 0, 0], [0, 25, 25, 0, 0, 0, 25, 25, 0], [25, 25, 0, 0, 0, 0, 25, 0, 0], [0, 25, 0, 0, 0, 0, 25, 25, 0], [25, 25, 25, 0, 0, 0, 25, 0, 0], [25, 0, 0, 0, 0, 0, 0, 25, 0], [25, 25, 0, 0, 0, 0, 0, 50, 0], [0, 0, 0, 25, 0, 0, 25, 0, 0], [50, 25, 0, 0, 0, 0, 25, 25, 0]] 

total prize values:
 [(370, 100), (340, 50), (340, 100), (210, 75), (330, 75), (230, 100), (170, 50), (140, 100), (300, 50), (90, 125)] 

was board completed?:
 [False, False, False, False, False, False, False, False, False, False] 

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])
1 {'avg_prize': 2.908, 'expected_players': 1000, 'expected_cost': 2908.0, 'prob_covered': 0.0, 'sim_count': 5000}
2 {'avg_prize': 5.372, 'expected_players': 600, 'expected_cost': 3223.2, 'prob_covered': 0.0, 'sim_count': 5000}
3 {'avg_prize': 10.069, 'expected_players': 400, 'expected_cost': 4027.6000000000004, 'prob_covered': 0.0, 'sim_count': 5000}
4 {'avg_prize': 16.866, 'expected_players': 300, 'expected_cost': 5059.8, 'prob_covered': 0.0, 'sim_count': 5000}
5 {'avg_prize': 27.609, 'expected_players': 250, 'expected_cost': 6902.25, 'prob_covered': 0.0, 'sim_count': 5000}
6 {'avg_prize': 39.088, 'expected_players': 200, 'expected_cost': 7817.6, 'prob_covered': 0.0, 'sim_count': 5000}
7 {'avg_prize': 53.47, 'expected_players': 180, 'expected_cost': 9624.6, 'prob_covered': 0.0, 'sim_count': 5000}
8 {'avg_prize': 72.025, 'expected_players': 160, 'expected_cost': 11524.0, 'prob_covered': 0.0, 'sim_count': 5000}
9 {'avg_prize': 88.337, 'expected_players': 140, 'expected_cost': 12367.18, 'prob_covered': 0.0, 'sim_count': 5000}
10 {'avg_prize': 107.766, 'expected_players': 120, 'expected_cost': 12931.92, 'prob_covered': 0.0, 'sim_count': 5000}
11 {'avg_prize': 127.934, 'expected_players': 100, 'expected_cost': 12793.4, 'prob_covered': 0.0, 'sim_count': 5000}
12 {'avg_prize': 150.883, 'expected_players': 90, 'expected_cost': 13579.470000000001, 'prob_covered': 0.0, 'sim_count': 5000}
13 {'avg_prize': 171.384, 'expected_players': 80, 'expected_cost': 13710.72, 'prob_covered': 0.0004, 'sim_count': 5000}
14 {'avg_prize': 195.769, 'expected_players': 70, 'expected_cost': 13703.83, 'prob_covered': 0.002, 'sim_count': 5000}
15 {'avg_prize': 219.257, 'expected_players': 60, 'expected_cost': 13155.42, 'prob_covered': 0.0036, 'sim_count': 5000}
16 {'avg_prize': 246.868, 'expected_players': 50, 'expected_cost': 12343.4, 'prob_covered': 0.0056, 'sim_count': 5000}
17 {'avg_prize': 268.351, 'expected_players': 40, 'expected_cost': 10734.04, 'prob_covered': 0.012, 'sim_count': 5000}
18 {'avg_prize': 292.598, 'expected_players': 30, 'expected_cost': 8777.94, 'prob_covered': 0.014, 'sim_count': 5000}
19 {'avg_prize': 318.353, 'expected_players': 15, 'expected_cost': 4775.295, 'prob_covered': 0.0268, 'sim_count': 5000}
20 {'avg_prize': 341.62, 'expected_players': 25, 'expected_cost': 8540.5, 'prob_covered': 0.035, 'sim_count': 5000}
21 {'avg_prize': 370.822, 'expected_players': 40, 'expected_cost': 14832.880000000001, 'prob_covered': 0.0448, 'sim_count': 5000}
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.

now we've run the simulation and have the results, lets do the final calculations

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]:
262425.545

↑ how does this compare to budget target? -- if too low, raise prize values, or frequency

In [16]:
prob_at_least_one_winner
Out[16]:
0.9909658203888445

↑ aim for about 96-98% -- too easy, and grand prize will be won too early

↓ aim for 2-4

In [17]:
other_board_finishers
Out[17]:
3.6369999999999996
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]:
25993.75
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]:
{'Wall Street': 3627,
 '5th Avenue': 3627,
 'Broadway': 1813,
 'Tropicana Ave': 2418,
 'Fremont St': 2418,
 'Las Vegas Blvd': 1209,
 'Pacific Coast Hwy': 3627,
 'Sunset Blvd': 3627,
 'Rodeo Drive': 906,
 'Michigan Ave': 1813,
 'Bourbon Street': 906}

we're done! (sort-of)

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]:
(0.9909658203888445, 0.7930326534589696, 0.32195140820858736)

Note about randomness:

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]:
1155000

we can plot a histogram of every piece selected, but be careful -- when we do a lot of runs, we'll have millions of data points, which may require a long time to compute the histogram

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()