When you learn to program for the first time, you look for — or, perhaps, are assigned — projects that reinforce basic concepts. But how often do you, once you've attained more knowledge and experience, revisit those beginner projects from the perspective of an advanced programmer?

In this article, I want to do just that. I want to revisit a common beginner project — implementing the game "Rock Paper Scissors" in Python — with the knowledge I've gained from nearly eight years of Python programming experience.

The Rules Of "Rock Paper Scissors"

Before diving into code, let's set the stage by outlining how "Rock Paper Scissors" is played. Two players each choose one of three items: rock, paper, or scissors. The players reveal their selection to each other simultaneously and the winner is determined by the following rules:

  1. Rock beats scissors
  2. Scissors beats paper
  3. Paper beats rock

Growing up, my friends and I used "Rock Paper Scissors" to solve all sorts of problems. Who gets to play first in a one-player video game? Who gets the last can of soda? Who has to go pick up the mess we just made? Important stuff.

The Requirements

Let's lay out some requirements for the implementation. Rather than building a full-blown game, let's focus on writing a function called play() that accepts two string arguments — the choice of "rock", "paper", or "scissors" selected by each player — and returns a string indicating the winner (e.g., "paper wins") or if the game results in a tie (e.g., "tie").

Here are some examples of how play() is called and what it returns:

>>> play("rock", "paper")
'paper wins'

>>> play("scissors", "paper")
'scissors wins'

>>> play("paper", "paper")
'tie'


If one or both of the two arguments are invalid, meaning they aren't one of "rock", "paper", or "scissors", then play() should raise some kind of exception.

play() should also be commutative. That is, play("rock", "paper") should return the same thing as play("paper", "rock").



The "Beginner" Solution

To set a baseline for comparison, consider how a beginner might implement the play() function. If this beginner is anything like I was when I first learned to program, they'd probably start writing down a whole bunch of if statements:

def play(player1_choice, player2_choice):
    if player1_choice == "rock":
        if player2_choice == "rock":
            return "tie"
        elif player2_choice == "paper":
            return "paper wins"
        elif player2_choice == "scissors":
            return "rock wins"
        else:
            raise ValueError(f"Invalid choice: {player2_choice}")
    elif player1_choice == "paper":
        if player2_choice == "rock":
            return "paper wins"
        elif player2_choice == "paper":
            return "tie"
        elif player2_choice == "scissors":
            return "rock wins"
        else:
            raise ValueError(f"Invalid choice: {player2_choice}")
    elif player1_choice == "scissors":
        if player2_choice == "rock":
            return "rock wins"
        elif player2_choice == "paper":
            return "scissors wins"
        elif player2_choice == "scissors":
            return "tie"
        else:
            raise ValueError(f"Invalid choice: {player2_choice}")
    else:
        raise ValueError(f"Invalid choice: {player1_choice}")

Strictly speaking, there's nothing wrong with this code. It runs without error and meets all of the requirements. It's also similar to a number of high-ranking implementations for the Google search "rock paper scissors python."

Experienced programmers will quickly recognize a number of code smells, though. In particular, the code is repetitive and there are many possible execution paths.

Advanced Solution #1

One way to implement "Rock Paper Scissors" from a more advanced perspective involves leveraging Python's dictionary type. A dictionary can map items to those that they beat according to the rules of the game.

Let's call this dictionary loses_to (naming is hard, y'all):

loses_to = {
    "rock": "scissors",
    "paper": "rock",
    "scissors": "paper",
}

loses_to provides a simple API for determining which item loses to another:

>>> loses_to["rock"]
'scissors'

>>> loses_to["scissors"]
'paper'

A dictionary has a couple of benefits. You can use it to:

  1. Validate chosen items by checking for membership or raising a KeyError
  2. Determine a winner by checking if a value loses to the corresponding key

With this in mind, the play() function could be written as follows:

def play(player1_choice, player2_choice):
    if player2_choice == loses_to[player1_choice]:
        return f"{player1_choice} wins"
    if player1_choice == loses_to[player2_choice]:
        return f"{player2_choice} wins"
    if player1_choice == player2_choice:
        return "tie"

In this version, play() takes advantage of the built-in KeyError raised by the loses_to dictionary when trying to access an invalid key. This effectively validates the players' choices. So if either player chooses an invalid item — something like "lizard" or 1234play() raises a KeyError:

>>> play("lizard", "paper")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 2, in play
KeyError: 'lizard'

Although the KeyError isn't as helpful as a ValueError with a descriptive message, it still gets the job done.

The new play() function is much simpler than the original one. Instead of handling a bunch of explicit cases, there are only three cases to check:

  1. player2_choice loses to player1_choice
  2. player1_choice loses to player2_choice
  3. player1_choice and player2_choice are the same

There's a fourth hidden case, however, that you almost have to squint to see. That case occurs when none of the other three cases are true, in which case play() returns a None value.

But... can this case ever really occur? Actually, no. It can't. According to the rules of the game, if player 1 doesn't lose to player 2 and player 2 doesn't lose to player 1, then both players must have chosen the same item.

In other words, we can remove the final if block from play() and just return "tie" if neither of the other two if blocks execute:

def play(player1_choice, player2_choice):
    if player2_choice == loses_to[player1_choice]:
        return f"{player1_choice} wins"
    if player1_choice == loses_to[player2_choice]:
        return f"{player2_choice} wins"
    return "tie"

We've made a tradeoff. We've sacrificed clarity — I'd argue that there's a greater cognitive load required to understand how the above play() function works compared to the "beginner" version — in order to shorten the function and avoid an unreachable state.

Was this trade-off worth it? I don't know. Does purity beat practicality?

Advanced Solution #2

The previous solution works great. It's readable and much shorter than the "beginner" solution. But it's not very flexible. That is, it can't handle variations of "Rock Paper Scissors" without rewriting some of the logic.

For instance, there's a variation called "Rock Paper Scissors Lizard Spock" with a more complex set of rules:

  1. Rock beats scissors and lizard
  2. Paper beats rock and Spock
  3. Scissors beats paper and lizard
  4. Lizard beats Spock and paper
  5. Spock beats scissors and rock

How can you adapt the code to handle this variation?

First, replace the string values in the loses_to dictionary with Python sets. Each set contains all of the items that lose to the corresponding key. Here's what this version of loses_to looks like using the original "Rock Paper Scissors" rules:

loses_to = {
    "rock": {"scissors"},
    "paper": {"rock"},
    "scissors": {"paper"},
}

Why sets? Because we only care about what items lose to a given key. We don't care about the order of those items.

To adapt play() to handle the new loses_to dictionary, all you have to do is replace == with in to use a membership check instead of an equality check:

def play(player1_choice, player2_choice):
    #                 vv--- replace == with in
    if player2_choice in loses_to[player1_choice]:
        return f"{player1_choice} wins"
    #                 vv--- replace == with in
    if player1_choice in loses_to[player2_choice]:
        return f"{player2_choice} wins"
    return "tie"

Take a moment to run this code and verify that everything still works.

Now replace loses_to with a dictionary implementing the rules for "Rock Paper Scissors Lizard Spock." Here's what that looks like:

loses_to = {
    "rock": {"scissors", "lizard"},
    "paper": {"rock", "spock"},
    "scissors": {"paper", "lizard"},
    "lizard": {"spock", "paper"},
    "spock": {"scissors", "rock"},
}

The new play() function works with these new rules flawlessly:

>>> play("rock", "paper")
'paper wins'

>>> play("spock", "lizard")
'lizard wins'

>>> play("spock", "spock")
'tie'

In my opinion, this is a great example of the power of picking the right data structure. By using sets to represent all of the items that lose to a key in the loses_to dictionary and replacing == with in, you've made a more general solution without having to add a single line of code.

Advanced Solution #3

Let's step back and take a slightly different approach. Instead of looking up items in a dictionary to determine the winner, we'll build a table of all possible inputs and their outcomes.

You still need something to represent the rules of the game, so let's start with the loses_to dict from the previous solution:

loses_to = {
    "rock": {"scissors"},
    "paper": {"rock"},
    "scissors": {"paper"},
}

Next, write a function build_results_table() that takes a rules dictionary, like loses_to, and returns a new dictionary that maps states to their results. For instance, here's what build_results_table() should return when called with loses_to as its argument:

>>> build_results_table(loses_to)
{
    {"rock", "scissors"}: "rock wins",
    {"paper", "rock"}: "paper wins",
    {"scissors", "paper"}: "scissors wins",
    {"rock", "rock"}: "tie",
    {"paper", "paper"}: "tie",
    {"scissors", "scissors"}: "tie",
}

If you think something looks off there, you're right. There are two things wrong with this dictionary:

  1. Sets like {"rock", "rock"} can't exist. Sets can't have repeated elements. In a real scenario, this set would look like {"rock"}. You don't actually need to worry about this too much. I wrote those sets with two elements to make it clear what those states represent.
  2. You can't use sets as dictionary keys. But we want to use sets because they take care of commutativity for us automatically. That is, {"rock", "paper"} and {"paper", "rock"} evaluate equal to each other and should therefore return the same result upon lookup.

The way to get around this is to use Python's built-in frozenset type. Like sets, frozensets support membership checks, and they compare equal to another set or frozenset if and only if both sets have the same members. Unlike standard sets, however, frozenset instances are immutable. As a result, they can be used as dictionary keys.

To implement build_results_table() you could loop over each of the keys in the loses_to dictionary and build a frozenset instance for each of the strings values in the set corresponding to the key:

def build_results_table(rules):
     results = {}
     for key, values in rules.items():
         for value in values:
             state = frozenset((key, value))
             result = f"{key} wins"
             results[state] = result
     return results

This gets you about halfway there:

>>> build_results_table(loses_to)
{frozenset({'rock', 'scissors'}): 'rock wins',
 frozenset({'paper', 'rock'}): 'paper wins',
 frozenset({'paper', 'scissors'}): 'scissors wins'}

The states that result in a tie aren't covered, though. To add those, you need to create frozenset instances for each key in the rules dictionary that map to the string "tie":

def build_results_table(rules):
     results = {}
     for key, values in rules.items():
         # Add the tie states
         results[frozenset((key,))] = "tie"  # <-- New
         # Add the winning states
         for value in values:
             state = frozenset((key, value))
             result = f"{key} wins"
             results[state] = result
     return results

Now the value returned by build_results_table() looks right:

>>> build_results_table(loses_to)
{frozenset({'rock'}): 'tie',
 frozenset({'rock', 'scissors'}): 'rock wins',
 frozenset({'paper'}): 'tie',
 frozenset({'paper', 'rock'}): 'paper wins',
 frozenset({'scissors'}): 'tie',
 frozenset({'paper', 'scissors'}): 'scissors wins'}

Why go through all this trouble? After all, build_results_table() looks more complicated than the play() function from the previous solution.

You're not wrong, but I want to point out that this pattern can be quite useful. If there are a finite number of states that can exist in a program, you can sometimes see dramatic boosts in speed by precalculating the results for all of those states. This might be overkill for something as simple as "Rock Paper Scissors," but could make a huge difference in situations where there are hundreds of thousands or even millions of states.

One real-world scenario where this type of approach makes sense is the Q-learning algorithm used in reinforcement learning applications. In that algorithm, a table of states — the Q-table — is maintained that maps each state to a set of probabilities for some pre-determined actions. Once an agent is trained, it can choose an action based on the probabilities for an observed state and then act accordingly.

Often, a table like the one generated by build_results_table() is computed and then stored in a file. When the program runs, the pre-computed table gets loaded into memory and then used by the application.

So, now that you have a function that can build a results table, assign the table for loses_to to an outcomes variable:

outcomes = build_results_table(loses_to)

Now you can write a play() function that looks up the state in the outcomes table based on the arguments passed to play and then returns the result:

def play(player1_choice, player2_choice):
    state = frozenset((player1_choice, player2_choice))
    return outcomes[state]

This version of play() is incredibly simple. Just two lines of code! You could even write it as a single line if you wanted to:

def play(player1_choice, player2_choice):
    return outcomes[frozenset((player1_choice, player2_choice))]

Personally, I prefer the two-line version over the single-line version.

Your new play() function follows the rules of the game and is commutative:

>>> play("rock", "paper")
'paper wins'

>>> play("paper", "rock")
'paper wins'

play() even raises a KeyError if it gets called with an invalid choice, but the error is less helpful now that the keys of the outcomes dictionary are sets:

>>> play("lizard", "paper")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 21, in play
    return outcomes[state]
KeyError: frozenset({'lizard', 'paper'})

The vague error would likely not be an issue, however. In this article, you're only implementing the play() function. In a true implementation of "Rock Paper Scissors," you'd most likely capture user input and validate that before ever passing the user's choice to play().

So, how much faster is this implementation versus the previous ones? Here are some timing results to compare the performance of the various implementations using IPython's %timeit magic function. play1() is the version of play() from the Advanced Solution #2 section, and play2() is the current version:

In [1]: %timeit play1("rock", "paper")
141 ns ± 0.0828 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

In [2]: %timeit play2("rock", "paper")
188 ns ± 0.0944 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)

In this case, the solution using the results table is actually slower than the previous implementation. The culprit here is the line that converts the function arguments to a frozenset. So, although dictionary lookups are fast, and building a table that maps states to outcomes can potentially improve performance, you need to be careful to avoid expensive operations that may end up negating whatever gains you expect to get.

Conclusion

I wrote this article as an exercise. I was curious to know how I'd approach a beginner project like "Rock Paper Scissors" in Python now that I have a lot of experience. I hope you found it interesting. If you have any inkling of inspiration now to revisit some of your own beginner projects, then I think I've done my job!

If you do revisit some of your own beginner projects, or if you've done so in the past, let me know how it went in the comments. Did you learn anything new? How different is your new solution from the one you wrote as a beginner?

What Inspired This Article?

An acquaintance from the Julia world, Miguel Raz Guzmán Macedo, turned me on to a blog post by Mosè Giordano. Mosè leverages Julia's multiple dispatch paradigm to write "Rock Paper Scissors" in less than ten lines of code:

Rock–paper–scissors game in less than 10 lines of code
Rock–paper–scissors is a popular hand game. However, some nerds may prefer playing this game on their computer rather than actually shaking their hands. Image credit: Enzoklop, Wikimedia Commons, CC-BY-SA 3.0 We can write this game in less than 10 lines of code in the Julia programming language. Thi…

I won't get into the details of how Mosè's code works. Python doesn't even support multiple dispatch out-of-the-box. (Although you can use it with some help from the plum package.)

Mosè's article got my mental gears spinning and encouraged me to revisit "Rock Paper Scissors" in Python to think about how I could approach the project differently.

As I was working through the solution, however, I was reminded of an article I did a reviewed for Real Python quite some time ago:

Make Your First Python Game: Rock, Paper, Scissors! – Real Python
In this tutorial, you’ll learn to program rock paper scissors in Python from scratch. You’ll learn how to take in user input, make the computer choose a random action, determine a winner, and split your code into functions.

It turns out the first two solutions I "invented" here are similar to the solution that Chris Wilkerson, the author of Real Python's article, came up with.

Chris's solution is more full-featured. It includes an interactive gameplay mechanism and even uses Python's Enum type to represent game items. That must have also been where I first heard of "Rock Paper Scissors Lizard Spock."