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:
- Rock beats scissors
- Scissors beats paper
- 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.
Want more like this?
One email, every Saturday, with one actionable tip.
Always less than 5 minutes of your time.
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:
- Validate chosen items by checking for membership or raising a
KeyError
- 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 1234
— play()
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:
player2_choice
loses toplayer1_choice
player1_choice
loses toplayer2_choice
player1_choice
andplayer2_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:
- Rock beats scissors and lizard
- Paper beats rock and Spock
- Scissors beats paper and lizard
- Lizard beats Spock and paper
- 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.
Get over the "intermediate hump."
One email, every Saturday, to challenge your skills and inspire your curiosity.
Always less than 5 minutes of your time.
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:
- 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. - 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:

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:

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."