
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&aposve attained more knowledge and experience, revisit those beginner projects from the perspective of an 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&aposve gained from nearly eight years of Python programming experience.
Table of Contents
- The Rules of "Rock Paper Scissors"
- The Requirements
- The "Beginner" Solution
- Advanced Solution #1
- Advanced Solution #2
- Advanced Solution #3
- Conclusion
- What Inspired This Article?
The Rules Of "Rock Paper Scissors"
Before diving into code, let&aposs 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 simulataneously 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&aposs lay out some requirements for the implementation. Rather than building a full-blown game, let&aposs 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")
&aposrock wins&apos
>>> play("scissors", "paper")
&aposscissors wins&apos
>>> play("paper", "paper")
&apostie&apos
If one or both of the two arguments are invalid, meaning they aren&apost 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&aposd 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&aposs nothing wrong with this code. It runs without error and meets all of the requirements. It&aposs 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&aposs dictionary type. A dictionary can map items to those that they beat according to the rules of the game.
Let&aposs call this dictionary loses_to
(naming is hard, y&aposall):
loses_to = {
"rock": "scissors",
"paper": "rock",
"scissors": "paper",
}
loses_to
provides a simple API for determining which item loses to another:
>>> loses_to["rock"]
&aposscissors&apos
>>> loses_to["scissors"]
&apospaper&apos
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&apos 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: &aposlizard&apos
Although the KeyError
isn&apost 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&aposs 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&apost. According to the rules of the game, if player 1 doesn&apost lose to player 2 and player 2 doesn&apost 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&aposve made a tradeoff. We&aposve sacrificed clarity — I&aposd argue that there&aposs 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&apost know. Does purity beat practicality?
Advanced Solution #2
The previous solution works great. It&aposs readable and much shorter than the "beginner" solution. But it&aposs not very flexible. That is, it can&apost handle variations of "Rock Paper Scissors" without rewriting some of the logic.
For instance, there&aposs 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&aposs 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&apost 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&apos 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")
&apospaper wins&apos
>>> play("spock", "lizard")
&aposlizard wins&apos
>>> play("spock", "spock")
&apostie&apos
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&aposve made a more general solution without having to add a single line of code.
Advanced Solution #3
Let&aposs step back and take a slightly different approach. Instead of looking up items in a dictionary to determine the winner, we&aposll build a table of all possible inputs and their outcomes.
You still need something to represent the rules of the game, so let&aposs start with the loses_to
dict from 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&aposs 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&aposre right. There are two things wrong with this dictionary:
- Sets like
{"rock", "rock"}
can&apost exist. Sets can&apost have repeated elements. In a real scenario, this set would look like{"rock"}
. You don&apost 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&apost 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&aposs built-in frozenset
type. Like sets, frozensets
suport 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({&aposrock&apos, &aposscissors&apos}): &aposrock wins&apos,
frozenset({&apospaper&apos, &aposrock&apos}): &apospaper wins&apos,
frozenset({&apospaper&apos, &aposscissors&apos}): &aposscissors wins&apos}
The states that result in a tie aren&apost 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({&aposrock&apos}): &apostie&apos,
frozenset({&aposrock&apos, &aposscissors&apos}): &aposrock wins&apos,
frozenset({&apospaper&apos}): &apostie&apos,
frozenset({&apospaper&apos, &aposrock&apos}): &apospaper wins&apos,
frozenset({&aposscissors&apos}): &apostie&apos,
frozenset({&apospaper&apos, &aposscissors&apos}): &aposscissors wins&apos}
Why go through all this trouble? After all, build_results_table()
looks more complicated than the play()
function from previous solution.
You&aposre 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 and 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")
&apospaper wins&apos
>>> play("paper", "rock")
&apospaper wins&apos
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({&aposlizard&apos, &apospaper&apos})
The vague error would likely not be an issue, however. In this article, you&aposre only implementing the play()
function. In a true implementation of "Rock Paper Scissors" you&aposd most likely capture user input and validate that before ever passing the user&aposs choice to play()
.
So, how much faster is this implementation versus the previous ones? Here&aposs some timing results to compare performance of the various inmplementations using IPython&aposs %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&aposd 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&aposve done my job!
If you do revist some of your own beginner projects, or if you&aposve 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 to the one you wrote as a beginner?
What Inspired This Article?
An aquantaince from the Julia world, Miguel Raz Guzmán Macedo, turned me on to a blog post by Mosè Giordano. Mosè leverages Julia&aposs multiple dispatch paradigm to write "Rock Paper Scissors" in less than ten lines of code:

I won&apost get into the details of how Mosè&aposs code works. Python doesn&apost even support multiple dispatch out-of-the-box. (Although you can use it with some help from the plum
package.)
Mosè&aposs 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&aposs article, came up with.
Chris&aposs solution is more full-featured. It includes an interactive gameplay mechanism and even uses Python&aposs Enum
type to represent game items. That must have also been where I first heard of "Rock Paper Scissors Lizard Spock."
Did you enjoy this article? Stay up-to-date with all of my content, get early access to my courses, and get hand-picked content from around the Python and Julia communities straight to your inbox every Friday by signing up for my weekly Curious About Code newsletter.