1
\$\begingroup\$

Years ago, I wrote some code in Perl for the Chutes and Ladders game. I re-wrote the code here in Python.

The code simulates one player in a game to see how many spins of the dial are needed to win the game. Many games can be simulated, set by the -games command line option.

Here is the output for a typical run for 5 games:

game=1 spins=63
game=2 spins=25
game=3 spins=68
game=4 spins=29
game=5 spins=36

Observations based on 1 million games:

Metric Number of spins
Mean 39
Median 33
Mode 22
Minimum 7

You'll most likely win in 22 spins.

This code merely dumps out raw data. Statistical analysis software sold separately.


I feel like I am violating DRY in the special_square assignment with all those 'up ladder' and 'down chute' strings.

Any feedback is welcome.


#!/usr/bin/env python3

"""

Generate statistics for the Chutes and Ladders game.

The game board has 100 squares at positions numbered 1 to 100.
There are 10 chutes and 9 ladders.
A spin of the dial results in a random number between 1 and 6.
It is not possible to climb ladder1 or ladder80 more than once in a game.
There are no such limits on the other ladders or any of the chutes.

"""

import argparse
import random

def game(game_num: int, verbose: bool=0):
    """
    Run a simulation of one game.
    Prints statistics for the game.

    """

    # Define the squares on the board which have special behavior,
    # namely a ladder or a chute.
    # The keys to the outer dictionary represent
    # the starting position of a chute or ladder.
    special_square = {
        # Ladders
        1  : {'end' :  38, 'action' : 'up ladder' },
        4  : {'end' :  14, 'action' : 'up ladder' },
        9  : {'end' :  31, 'action' : 'up ladder' },
        21 : {'end' :  42, 'action' : 'up ladder' },
        28 : {'end' :  84, 'action' : 'up ladder' },
        36 : {'end' :  44, 'action' : 'up ladder' },
        51 : {'end' :  67, 'action' : 'up ladder' },
        71 : {'end' :  91, 'action' : 'up ladder' },
        80 : {'end' : 100, 'action' : 'up ladder' },
        # Chutes
        16 : {'end' :   6, 'action' : 'down chute'},
        48 : {'end' :  26, 'action' : 'down chute'},
        49 : {'end' :  11, 'action' : 'down chute'},
        56 : {'end' :  53, 'action' : 'down chute'},
        62 : {'end' :  19, 'action' : 'down chute'},
        64 : {'end' :  60, 'action' : 'down chute'},
        87 : {'end' :  24, 'action' : 'down chute'},
        93 : {'end' :  73, 'action' : 'down chute'},
        95 : {'end' :  75, 'action' : 'down chute'},
        98 : {'end' :  78, 'action' : 'down chute'},
    }

    # The starting position is the imaginary location of 0,
    # which is off the board
    position = 0

    # Spin the dial and move until you reach the top of the board
    spin_count = 0
    while position != 100:
        # Simulate a spin of the dial, from 1 to 6
        dial = random.randrange(1, 7)
        spin_count += 1

        # Calculate the new position based on the spin value
        next_position = position + dial

        # Update the position, if needed.
        # Also determine what action was taken for verbose output.
        if next_position > 100:
            # position does not change
            action = 'stay'
        elif next_position in special_square:
            position = special_square[next_position]['end'   ]
            action   = special_square[next_position]['action']
        else:
            position = next_position
            action   = 'advance'

        if verbose:
            print(f'spin_count={spin_count:3d}   dial={dial}   end_position={position:3d}   {action}')

    print(f'game={game_num} spins={spin_count}')

parser = argparse.ArgumentParser(description = 'Chutes and Ladders stats')
parser.add_argument('-g', '--games', type=int, help='Number of games to simulate', default=1)
parser.add_argument('-v', '--verbose', help='Display of all spins', action='store_true')
args = parser.parse_args()

for i in range(1, args.games+1):
    game(i, args.verbose)
\$\endgroup\$
1
  • \$\begingroup\$ Whether you go up a ladder or down a chute you can tell by whether the end position comes before or after the current position, there’s no need to explicitly write those actions. \$\endgroup\$ Apr 22 at 20:09

1 Answer 1

3
\$\begingroup\$

CLI --help

import argparse

This works. And it's "batteries included".

But consider doing "import typer" followed by typer.run(game), so your argv will be cracked for you "for free".

As an extra bonus, when you tack on a 3rd argument to e.g. accommodate alternate board topologies, the CLI will auto-adjust. Mostly, I appreciate that it's something I don't even have to think about, kind of like I don't give order-of-imports any thought at all since isort will surely fix it.

annotation

def game( ... , verbose: bool=0):

Now what's going on there? I know bool is int, pretty much. But still. If only for human readers, give us a False there, please. Plus, we'll want mypy to lint cleanly.

I really like the module-level docstring.

Observing that position 0 is "off board" is a helpful comment.

digraph

I agree with @CrisLuengo's comment that up/down is readily derived from start < end.

Consider doing "import networkx" or similar. Then you could do things like ask for all cycles.

enum

            action = 'stay'
            ...
            action   = 'advance'

Consider defining an enum for this.

randint

        # Simulate a spin of the dial, from 1 to 6
        dial = random.randrange(1, 7)

I like half-open intervals in general, since they compose nicely. And I like the randrange() API, which makes it clear that here we'll never get back a 7.

But after looking at the comment you wrote, consider using .randint(1, 6) on this particular call. It suggests to a human reader that we're rolling the dice.

logged output format

I like the if verbose:. Since the intent of the logged lines is that they'll be parsed by a stat analysis module, consider writing them in a code-friendly format such as CSV or JSON.

design of Public API

    print(f'game={game_num} ...')

...
for i in range(1, args.games+1):
    game(i, args.verbose)

Passing in the game_num seems awkward, given that it doesn't really matter to game() whether we're on the first or the thousandth game. Prefer to return spin_count, instead of the current None. That way the caller can fend for itself.

main guard

for i in range(1, args.games+1):
    game(i, args.verbose)

Please put an if __name__ == "__main__": guard around that, so other modules can safely import this. For example, any unit tests that you might write.

Nice shebang, BTW.


This codebase is clear and achieves its design goals.

I would be willing to delegate or accept maintenance tasks on it.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Well, I'm not so sure about my "Passing in the game_num seems awkward" remark, since verbose logging needs it. IDK, maybe pass in a curried logger callback? Which could be a no-op function for the quiet case. Then the logger would be responsible for knowing such details as "which game is this?". \$\endgroup\$
    – J_H
    Apr 23 at 0:47

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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