15
\$\begingroup\$

Inspired by the video from Scam Nation and James Grime from Numberphile, I tried to make a Razzle Dazzle simulator.

Razzle Dazzle is a scam in the form of a game. Per turn, the player pays a fee and throws 8 marbles onto a board, so they land in holes in the board. Each hole has a score from 1 to 6. Throwing 8 dice instead can also be done. The scores are added to form a score from 8 to 48. This score is translated into points via table/chart. The points are accumulated across turns. When the player reaches 100 points, it wins a prize. Some scores increase the number of prizes when 100 points are reached. A score of 29 doubles the fee per turn, multiplicatively, so scoring 29 10 times increases the fee to 1024x the initial fee.

enter image description here

The trick is that the most common scores (22-34) do not give any points. This means that only 2.7% of the turns by fair dice rolls give out points, needing 369.5 turns to reach 100 points. For the board in the video, only 0.28% give points, resulting in 5000+ turns to get 100 points. The probability to score 29 is about 8%, this leads to massive fees when playing lots of turns.

import random, numpy
import matplotlib.pyplot as plt

# return one int with random value [1,6], with the probability density described in rawMassDist
# every 1000 turns, sample 1000 loaded die throws and put them in a list
randoms = []
idxRandom = 0
def throwLoadedDie():
    global idxRandom
    global randoms
    rawMassDist = [11, 17, 39, 44, 21, 11]
    #rawMassDist = [50, 5, 5, 5, 5, 50]
    massDist = [float(i)/sum(rawMassDist) for i in rawMassDist]
    if (idxRandom % 1000) == 0:
        #randoms = numpy.random.choice(range(1, 7), size=1000, p=massDist)
        randoms = random.choices(range(1,7), massDist, k=1000)
        idxRandom = 0
    idxRandom += 1
    return randoms[idxRandom-1]

# throw 8 dice, fairDice indicates whether fair dice or loaded dice are used
# returns the sum of the dice values, which equals the score for this turn
def throwDice():
    total = 0
    for _ in range(0,8):
        if fairDice:
            total += random.randint(1,6);
        else:
            total += throwLoadedDie()
    return total

# translates the score into points using dictionary toPoints
def getPoints(score):
    toPoints = {8:100, 9:100, 10:50, 11:30, 12:50,
    13:50, 14:20, 15:15, 16:10, 17:5, 
    39:5, 40:5, 41:15, 42:20, 43:50, 
    44:50, 45:50, 46:50, 47:50, 48:100}
    if score in toPoints:
        return toPoints[score]
    return 0

# returns if this score results in an extra price
def isExtraPrize(score):
    if (18 <= score <= 21) or (score == 29) or (35 <= score <= 38):
        return True
    return False

# returns if this score doubles the fee for one turn
def needDoubleFee(score):
    return score == 29

# simulate one turn, return the new number of points, prizes and fee for the next turn
def simulateTurn(points, prizes, fee):
    score = throwDice()
    if isExtraPrize(score):
        prizes += 1
    if needDoubleFee(score):
        fee *= 2
    points += getPoints(score)
    return [points, prizes, fee, score]

# simulate single game, can result in win or loss in maxTurns turns
# can print result and histogram of scores
def playGame(printResult = True, maxTurns = 1000):
    points = 0
    prizes = 1
    hist = list() # start with empty list, add score after every turn
    hist2 = [0]*49 # entries 0-7 is always 0, other entries 8-48 represent the number of times a score has occurred
    fee = 1
    totalFee = 0
    goal = 100
    won = False
    for turn in range(1, maxTurns+1):
        #print('Turn {0}, points: {1}'.format(turn, points))
        totalFee += fee
        [points, prizes, fee, score] = simulateTurn(points, prizes, fee)
        hist.append(score)
        if points >= goal:
            won = True
            break

    # finalize
    [hist2, _] = numpy.histogram(hist, bins=49, range=[0,48])
    if printResult:
        if won:
            print('You win {0} prizes in {1} turns, cost: {2}'.format(prizes, turn, totalFee))
        else:
            print('You only got {0} points in {1} turns, cost: {2}'.format(points, turn, totalFee))

        print(hist2)
    if not won:
        prizes = 0
    return [prizes, turn, totalFee, hist2]

# simulate multiple games, allow many turns per game to practically ensure win
# also disable result printing in each game
def playGames(numGames, plot=False):
    hist = [0]*49
    totalPrizes = 0
    totalTurns = 0
    totalFee = 0
    withPoints = 0
    gamesLost = 0
    for i in range(0, numGames):
        [prizes, turns, fee, hist2] = playGame(False, 100000)
        if prizes == 0:
            gamesLost += 1
        hist = [x + y for x, y in zip(hist, hist2)]
        totalPrizes += prizes
        totalFee += fee
        totalTurns += turns
    for i in range(8, 18):
        withPoints += hist[i]
    for i in range(39, 49):
        withPoints += hist[i]
    print('{0} games, lost {1}'.format(numGames, gamesLost))
    print('Avg prizes: {}'.format(totalPrizes/numGames))
    print('Avg turns: {}'.format(totalTurns/numGames))
    print('Avg fee: {}'.format(totalFee/numGames))
    print(hist)
    print('Percentage turns with points: {:.2f}'.format(100.0*withPoints/sum(hist)))

    if plot:
        # create list of colors to color each bar differently
        colors = [item for sublist in [['red']*18, ['blue']*21, ['red']*10] for item in sublist]
        plt.bar(range(0, 49), hist, color=colors)
        plt.title('Score distribution across multiple games')
        plt.xlabel('Score = sum of 8 dice')
        plt.ylabel('Number of turns')
        plt.text(40, 0.6*max(hist), 'Red bars\ngive points')
        plt.show()

fairDice = False
#playGame()
playGames(100, plot=True)

Concrete questions:

  1. Since calling random.choices() has some overhead, I generate 1000 loaded die rolls and put it in a global array. Is there a better of doing this without classes? In C I'd probably use static variables.
  2. To generate a histogram of all the scores during a game, I append to a list every turn, and then generate the histogram. Is this efficient performance-wise?
  3. How are my names? Especially hist, hist2, isExtraPrize() and needDoubleFee().
  4. My Ryzen 5 2400G with 3200 MHz RAM takes about 15s to simulate 100 loaded games, averaging. 3550 turns per game. I somehow feel like this should be faster, any performance related suggestions are welcome.
  5. And of course, general code review answers are welcome.
\$\endgroup\$
  • 1
    \$\begingroup\$ Is there a reason to avoid classes? Much of this seems like a class or two would be useful. \$\endgroup\$ – Austin Hastings Jun 10 at 3:16
  • \$\begingroup\$ No particular reason, I guess using class also avoids passing [points,prizes,fee] back and forth. \$\endgroup\$ – user2966394 Jun 10 at 15:41
8
\$\begingroup\$

First, your use of camelCase isn't ideal in Python. For variable and function names, snake_case is preferred. I'll be using that with any re-written code that I show.


I think throw_dice can be improved a bit. You're checking for the value of fair_dice once per iteration in the function instead of once at the beginning. This will be negligible performance-wise, but it's unnecessary and checking once per loop suggests that it's a value that can change in the loop, which isn't the case here.

There's different ways of approaching this depending on how close to PEP you want to adhere to; but both ways I'll show depend on dispatching to a function using a conditional expression. Following PEP, you could do something like:

def throw_loaded_die():
    return 1 # For brevity

# Break this off into its own function
def throw_fair_die():
    return random.randint(1, 6)

def throw_dice():
    # Figure out what we need first
    roll_f = throw_fair_die if fair_dice else throw_loaded_die

    total = 0
    for _ in range(8):
        total += roll_f() # Then use it here

    return total

That cuts down on duplication which is nice. I also got rid of the 0 argument in the call to range as that's implicit if it isn't specified.

I think the separate def throw_fair_die is unfortunate though. For such a simple function that isn't needed anywhere else, I find it to be noisy, and looking around, I'm not the only one to feel this way. Personally, I'd prefer to just write:

def throw_dice():
    # Notice the lambda
    roll_f = (lambda: random.randint(1, 6)) if fair_dice else throw_loaded_die

    total = 0
    for _ in range(8): # Specifying the start is unnecessary when it's 0
        total += roll_f()

    return total

This is arguably a "named lambda" though, which is in violation of the recommendations of PEP:

Always use a def statement instead of an assignment statement that binds a lambda expression directly to an identifier.

¯\_(ツ)_/¯

I still think it can be improved though. Look carefully at the loop. It's just a summing loop! Python has a built-in for that that can be used cleanly with a generator expression:

def throw_dice():
    roll_f = throw_fair_die if fair_dice else throw_loaded_die

    return sum(roll_f() for _ in range(8))

is_extra_prize has a redundant return. It can be simplified to:

def is_extra_prize(score):
    return (18 <= score <= 21) or (score == 29) or (35 <= score <= 38)

I'll point out though that right below it you have need_double_fee. Either it's justified to have score == 29 broken off into its own function (in which case it should be used in the appropriate cases), or it's not. If you feel the need to have it as a separate function, I'd use it:

def need_double_fee(score):
    return score == 29

def is_extra_prize(score):
    return (18 <= score <= 21) or need_double_fee(score) or (35 <= score <= 38)

Although it could be argued that the other two parts of the condition in is_extra_prize are more complicated than score == 29, and may benefit from having a name attached to them as well. There's also the alternative of naming the 29 magic number directly, which I feel would probably be an even better option:

EXTRA_PRIZE_SCORE = 29

def is_extra_prize(score):
    return (18 <= score <= 21) or (score == EXTRA_PRIZE_SCORE) or (35 <= score <= 38)

You may find naming 18, 21, 35 and 38 are beneficial as well; although that will certainly make that function more verbose.


I think get_points can be improved as well. The score dictionary seems like it's a "member of the entire program", not something that should be local to the function. You can also use get on the dictionary to avoid the explicit membership lookup:

SCORE_TO_POINTS = {8:100, 9:100, 10:50, 11:30, 12:50,
                   13:50, 14:20, 15:15, 16:10, 17:5, 
                   39:5, 40:5, 41:15, 42:20, 43:50, 
                   44:50, 45:50, 46:50, 47:50, 48:100}

def get_points(score):
    # 0 is the default if the key doesn't exist
    return SCORE_TO_POINTS.get(score, 0)

simulate_turn returns a tuple (actually a list, although it probably should be a tuple) representing the new state of the game. This is fine for simple states, but your current state has four pieces, and accessing them requires memorizing what order they're in, and allows mistakes to be made if data is placed incorrectly. You may want to look into using a class here for organization and clarity, or even a named tuple as a shortcut.

In that same function, I'd also add some lines to space things out a bit:

def simulate_turn(points, prizes, fee):
    score = throwDice()

    if isExtraPrize(score):
        prizes += 1

    if needDoubleFee(score):
        fee *= 2

    points += getPoints(score)

    return (points, prizes, fee, score)

Personal style, but I like open space in code.

You could also do away with the mutation of the parameters:

def simulate_turn(points, prizes, fee):
    score = throw_dice()

    return (points + get_points(score),
            prizes + 1 if is_extra_prize(score) else prizes,
            fee * 2 if need_double_fee(score) else fee,
            score)

Although now that it's written out, I'm not sure how I feel about it.



I really only dealt with 5. here. Hopefully someone else can touch on the first four points.

\$\endgroup\$
  • \$\begingroup\$ There is no need for brackets in return (points, prizes, fee, score). Also get_points probably shouldn't be a function at all. \$\endgroup\$ – Georgy Jun 10 at 13:30
  • 1
    \$\begingroup\$ @Georgy For the first, true, but I prefer the explicitness of wrapping tuples. And for get_points, while it certainly isn't very complex, having it as a function prevents needing to worry about the default value every time you want to do the conversion, which is beneficial in a couple ways. Personally, I would have it as a function, but I also prefer having many small functions. \$\endgroup\$ – Carcigenicate Jun 10 at 14:56
3
\$\begingroup\$

Your original implementation of throwLoadedDie performs some unnecessary computations on each call, namely

rawMassDist = [11, 17, 39, 44, 21, 11]
massDist = [float(i)/sum(rawMassDist) for i in rawMassDist]

are computed on every call. Simply moving them to the if's body like

if (idxRandom % 1000) == 0:
    rawMassDist = [11, 17, 39, 44, 21, 11]
    massDist = [float(i) / sum(rawMassDist) for i in rawMassDist]

brought the computation time down from around 18s to less than 6s on my old laptop. Of course this can be optimized even further, since the weights don't change at all during the computation.

Combining this with a cool Python feature called generator expression, respectively the yield keyword, you can build something like

def throw_loaded_die(raw_mass_dist=(11, 17, 39, 44, 21, 11)):
    """return one random value from [1,6] following a probability density"""
    throws = []
    mass_dist_sum = sum(raw_mass_dist)
    mass_dist = [float(i) / mass_dist_sum for i in raw_mass_dist]
    while True:
        if not throws:
            throws = random.choices((1, 2, 3, 4, 5, 6), mass_dist, k=1000)
        yield throws.pop()

loaded_throws = throw_loaded_die()

which you can use like sum(next(loaded_throws) for _ in range(8)) in the throw_dice. As @Georgy pointed out in a comment, random.choices also works fine with raw_mass_dist, so there is no strict need to normalize for the non-NumPy version. For further explanations see this excellent Stack Overflow post.

I also created a version using NumPy and indexing - much like your original solution - to see if the performance can be improved even further.

    """return one random value from [1,6] following a probability density"""
    throws = []
    mass_dist = numpy.array(raw_mass_dist) / numpy.sum(raw_mass_dist)
    idx = 1000
    while True:
        if idx >= 1000:
            idx = 0
            throws = numpy.random.choice((1, 2, 3, 4, 5, 6), p=mass_dist, size=(1000, ))
        yield throws[idx]
        idx += 1

This implementation performs on par with my first proposed implementation when used in your script without any further changes. Some more extensive timing suggests that the NumPy/indexing version will win if you increase the number of throws significantly.

\$\endgroup\$
  • \$\begingroup\$ You don't need to calculate mass_dist, simply pass raw_mass_dist to random.choices. Also, I don't understand the point of generating a list of 1000 elements and then popping them one by one. Why not generate just one element each call? \$\endgroup\$ – Georgy Jun 10 at 15:24
  • \$\begingroup\$ @Georgy: The OP rightfully pointed out that random.choices has some calling overhead. If you use yield random.choices((1, 2, 3, 4, 5, 6), mass_dist, k=1)[0] you end up with a version that performs five times slower than the one with the internal buffer. \$\endgroup\$ – AlexV Jun 10 at 16:48
3
\$\begingroup\$
  1. How are my names? Especially hist, hist2, isExtraPrize() and needDoubleFee()

Not really great. For one, they don't follow PEP8 conventions, and, for two, there is no real reason for abreviating variable names anyway.

  1. Since calling random.choices() has some overhead, I generate 1000 loaded die rolls and put it in a global array. Is there a better of doing this without classes? In C I'd probably use static variables

You would have to generate 1000 random numbers anyway when simulating 1000 turns. And this should be the bottleneck of your code, so I’d rather avoid overly complicating things and make it straighforward. The only reason I’d still keep a function to generate a bunch of random values at once would be to keep the option of messing with the probability of rolling a particular number (the weights parameter of random.choices or the p parameter of numpy.random.choice).

  1. To generate a histogram of all the scores during a game, I append to a list every turn, and then generate the histogram. Is this efficient performance-wise?

I think you would probably benefit from using a Counter to, at least, update it efficiently and then convert it to the proper structure for display, if need be. But wait, there is an even better option:

  1. My Ryzen 5 2400G with 3200 MHz RAM takes about 15s to simulate 100 loaded games, averaging 3550 turns per game. I somehow feel like this should be faster, any performance related suggestions are welcome

Since you are loading numpy for some of your computation, why not delegate the entire simulation to it? Its fancy indexing can simplify your isExtraPrize and getPoints function. For instance:

>>> POINTS = np.array([
     0,   0,   0,   0,   0,   0,   0,   0, 100, 100,  50,  30,  50,
    50,  20,  15,  10,   5,   0,   0,   0,   0,   0,   0,   0,   0,
     0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,   0,
     5,   5,  15,  20,  50,  50,  30,  50, 100, 100])
>>> POINTS[[8, 22, 19, 48, 17]]
array([100,   0,   0, 100,   5])

You can then easily benefit from np.sum and np.cumsum on the resulting arrays and even perform vecorized mathematical operations to compute fees.

Using np.random.choice, you can generate up to \$8 \times 100000\$ die at once and discard those not needed if you reach 100 points before that many throws.

Example code using these:

import numpy as np
import matplotlib.pyplot as plt


def build_scoring_chart():
    prizes = np.zeros(49, dtype=bool)
    prizes[[*range(18, 22), 29, *range(35, 39)]] = True

    points = np.zeros(49, dtype=int)
    points[[*range(8, 18), *range(39, 49)]] = [
            100, 100, 50, 30, 50, 50, 20, 15, 10, 5,
            5, 5, 15, 20, 50, 50, 30, 50, 100, 100,
    ]

    return points, prizes


POINTS, PRIZES = build_scoring_chart()


def throw_die(amount=1000, weights=None):
    if weights is not None:
        weights = np.array(weights)
        weights /= weights.sum()
    return np.random.choice(range(1, 7), size=(amount, 8), p=weights).sum(axis=1)


def play_game(max_turns=1000):
    results = throw_die(max_turns)
    turns = POINTS[results].cumsum() < 100
    results = results[turns]  # keep only actual, meaninful, throws

    prizes = 1 + PRIZES[results].sum()
    fees = (2 ** (results == 29).cumsum(dtype=np.uint64)).sum()

    histogram, _ = np.histogram(results, bins=49, range=[0, 49])
    return prizes, len(results), fees, histogram


def play_games(games_count, max_turns=1000, verbose=False):
    throws_count = np.zeros(49, dtype=int)
    prizes = 0
    fees = 0
    turns = 0
    games_lost = 0
    points_scored = 0

    for _ in range(games_count):
        prizes_won, turns_played, fees_paid, histogram = play_game(max_turns)
        lost = turns_played == max_turns
        games_lost += lost
        if verbose:
            if lost:
                print('You couldn\'t achieve 100 points in', turns_played, 'turns but paid', fees_paid)
            else:
                print('You won', prizes_won, 'prizes in', turns_played, 'turns and paid', fees_paid)

        throws_count += histogram
        prizes += prizes_won
        fees += fees_paid
        turns += turns_played
        points_scored += histogram[POINTS != 0].sum()

    if verbose:
        print(games_count, 'games,', games_lost, 'lost')
        print('Average prizes:', prizes / games_count)
        print('Average turns:', turns / games_count)
        print('Average fees:', fees / games_count)
        print('Percentage of turns scoring points:', points_scored / throws_count.sum() * 100)

        # create list of colors to color each bar differently
        colors = ['red'] * 18 + ['blue'] * 21 + ['red'] * 10
        plt.bar(range(0, 49), throws_count, color=colors)
        plt.title('Score distribution across multiple games')
        plt.xlabel('Score = sum of 8 dice')
        plt.ylabel('Number of throws')
        plt.text(40, 0.6 * throws_count.max(), 'Red bars\ngive points')
        plt.show()


if __name__ == '__main__':
    play_games(100, 2000, True)

Note that I moved output in the outermost function to ease testing and reusability; and this meant losing the ability to print the score reached if unable to reach the 100 points mark.

This code runs in less than 3 seconds on my machine when generating 800000 random numbers per game, and in around half a second when generating 16000 numbers per game (as in the posted example).

\$\endgroup\$
  • \$\begingroup\$ Oh, nice! I came up with something very similar but didn't have time to polish it to show here. I also created a NamedTuple class with those prizes, turns, fees, games_lost packed in it, so I could use functools.reduce to add them in one line instead of writing prizes += prizes_won, fees += fees_paid, turns += turns_played etc. \$\endgroup\$ – Georgy Jun 13 at 11:04

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.