Rock, paper, scissors is a great game. Simple to grasp, yet surprisingly complex in terms of strategy. It's an ideal coding challenge to write a computer player capable of consistently winning the game. First though, we need a way to play the game.
Representing the game
We start with two players:
PLAYER1 = 1
PLAYER2 = 2
We then have three choices to play each round:
ROCK = 3
PAPER = 4
SCISSORS = 5
And a set of possible outcomes:
WIN = 6
LOSE = 7
DRAW = 8
With these symbols in place we can model the game state. That is, every possible way a round can end. Let us do this using a Python dictionary, mapping the two player choices (as a tuple) to the game outcome.
GAME_STATES = {
(ROCK, PAPER): LOSE,
(ROCK, SCISSORS): WIN,
(ROCK, ROCK): DRAW
(PAPER, SCISSORS): LOSE,
(PAPER, ROCK): WIN,
(PAPER, PAPER): DRAW,
(SCISSORS, ROCK): LOSE,
(SCISSORS, PAPER): WIN,
(SCISSORS, SCISSORS): DRAW
}
Now we can write a simple function to play one round of the game. We are going to assume each player is an instance of a class that we will define later. For now it is enough to know how this class is called, if not how it will be implemented.
def play_round(p1, p2):
""" Play one round of the game with the two supplied players. """
p1_choice = p1.pick()
p2_choice = p2.pick()
p1_outcome = GAME_STATES[(p1_choice, p2_choice)]
p2_outcome = GAME_STATES[(p2_choice, p1_choice)]
p1.record((p1_choice, p2_choice, p1_outcome))
p2.record((p2_choice, p1_choice, p2_outcome))
winner = 0
if p1_outcome == WIN:
winner = PLAYER1
elif p2_outcome == WIN:
winner = PLAYER2
return winner
The game round is very simple. We ask each player object to pick a choice to play. We then lookup the result for each player in the GAME_STATES
dictionary. We ask each player object to record the result of the game. Finally we return the winner to the caller, or zero if there was a draw.
So what does a player look like? First we define a base class to perform some common initialisation, and recording of the game round data.
class Player:
""" Base class for a player of the game. """
def __init__(self, name):
self.name = name
self.history = []
def pick(self):
"""Return one of the three choices: ROCK, PAPER, SCISSORS."""
raise NotImplementedError()
def record(self, game_details):
"""Record the details of the round."""
self.history.append(game_details)
Next we can inherit from this class and implement our picking strategy. Here we have a player that always plays ROCK
:
class AlwaysRockPlayer(Player):
""" Player who always chooses ROCK. """
def pick(self):
return ROCK
Not a great strategy I'm sure you'll agree, but we start with the simplest thing we can do. Let's write a slightly more complex player for our rock steady one to go up against:
class RandomPlayer(Player):
""" Player who always makes a random choice. """
def pick(self):
import random
random.seed()
return random.randint(ROCK, SCISSORS)
That at least gives us some variety in proceedings! It's still a very simple player though, as it ignores the game history completely. Let's pit these two players against each other in a few rounds.
>>> p1 = AlwaysRockPlayer("The Rock")
>>> p2 = RandomPlayer("Random Rick")
>>> play_round(p1, p2)
0
>>> play_round(p1, p2)
1
>>> play_round(p1, p2)
2
>>> play_round(p1, p2)
2
>>> play_round(p1, p2)
1
OK, so things are working. But to really get a feel for how they perform we need to run them for longer. Let's write a quick loop to run a 100 rounds between these two fearsome opponents:
>>> results = []
>>> for i in range(1,101):
... results.append(play_round(p1, p2))
... print(results[i-1], end='')
... if i % 10 == 0:
... print()
...
0102201112
0112202100
0121012101
1211200212
0110021121
0000120222
0102000202
2020021110
1112010101
1121020010
Better. A quick tot up of the results shows that... player 1 is the winner! Who'd have thought always playing ROCK
was a viable game strategy? OK, so the rock's random opponent is a little dumb in this regard. A human player would soon spot the pattern and counter it consistently. We need better players, and a proper arena for them to duel it out in.
New Arena
The new arena will play a game over several rounds between two players. It will print the game statistics at the end, so we can easily see how well each player has performed. The new code makes use of our existing play_round
function, and the code is given below:
def play_game(p1, p2, rounds=100):
""" Play several rounds of the game, reporting statistics at the end. """
print(f"{p1.name} vs {p2.name}")
results = []
for i in range(rounds):
results.append(play_round(p1, p2))
print(".", end="")
p1_total = len([x for x in results if x == PLAYER1])
p2_total = len([x for x in results if x == PLAYER2])
no_total = len([x for x in results if x == 0])
total = float(len(results))
print("")
print(f"{p1.name}: {(p1_total / total) * 100}%")
print(f"{p2.name}: {(p2_total / total) * 100}%")
print(f"Drawn games: {(no_total / total) * 100}%")
Let's pit our two players against each other in this new arena and see if The Rock
can retain its crown.
>>> p1 = AlwaysRockPlayer("The Rock")
>>> p2 = RandomPlayer("Random Rick")
>>> play_game(p1, p2)
The Rock vs Random Rick
....................................................................................................
The Rock: 39.0%
Random Rick: 31.0%
Drawn games: 30.0%
>>>
A more even spread this time. Let's try running for a 1000 rounds and see if that makes a difference.
>>> play_game(p1, p2, 1000)
The Rock vs Random Rick
........................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................................
The Rock: 34.300000000000004%
Random Rick: 34.5%
Drawn games: 31.2%
>>>
Huh, a much more even spread. In fact this is what we should have expected. With The Rock
always picking, well, ROCK
, the variation we are seeing is solely down to Random Rick
. If the PRNG (pseudo random number generator) in Python is any good, it'll be picking each choice roughly equally. The more rounds we play, the closer to an even split we would get.
No two ways about it, we still need a smarter player.
Remembering
We need a player that can learn from history without becoming predictable. But how to do that? Instead of picking a choice at random, the player picks an entry from its history
at random. Then it plays whatever would have beaten its opponent in that round. In theory, if the opponent is playing one choice more than the others, this should give us the edge.
Let's see what that looks like in code:
class RandomHistoryPlayer(Player):
""" Player who picks at random from historical options of their opponent. """
BEATEN_BY = {
ROCK: PAPER,
PAPER: SCISSORS,
SCISSORS: ROCK
}
def pick(self):
import random
random.seed()
total_rounds = len(self.history)
if total_rounds == 0:
choice = random.randint(ROCK, SCISSORS)
else:
idx = random.randint(0, len(self.history) - 1)
opponents_choice = self.history[idx][1]
choice = self.__class__.BEATEN_BY[opponents_choice]
return choice
When playing the first round, we don't have any history to work from, so we fallback to playing at random.
OK, moment of truth. Hoe does it perform?
>>> p1 = RandomHistoryPlayer("Historical Henry")
>>> p2 = AlwaysRockPlayer("The Rock")
>>> play_game(p1, p2)
Historical Henry vs The Rock
....................................................................................................
Historical Henry: 99.0%
The Rock: 0.0%
Drawn games: 1.0%
>>>
It obliterates The Rock
. With the exception of the random choice it makes first round, it won every other game. How does it perform against Random Rick
?
>>> p1 = RandomHistoryPlayer("Historic Henry")
>>> p2 = RandomPlayer("Random Rick")
>>> play_game(p1, p2)
Historic Henry vs Random Rick
....................................................................................................
Historic Henry: 32.0%
Random Rick: 41.0%
Drawn games: 27.0%
>>>
Oh dear. Best of three? So it fails against a purely random opponent. Surely we can do better?
Forgetting
The trouble with remembering everything is you can get stuck in old ways of thinking. We humans have learnt to forget, or at least discount, things that happened in the dim distant past. What we need is a way to weight the historical results, so that more recent games have a greater influence. Yes, that ought to do it. I'm sure this will give us the intelligent player we seek.
class WeightedHistoryPlayer(Player):
""" Computer player that linearly weights the past opponents choices. """
BEATEN_BY = {
ROCK: PAPER,
PAPER: SCISSORS,
SCISSORS: ROCK
}
def pick(self):
import random
random.seed()
total_rounds = len(self.history)
if total_rounds == 0:
choice = random.randint(ROCK, SCISSORS)
else:
weighted_outcomes = [(i / total_rounds, o) for i, o in enumerate(self.history)]
totals = { ROCK: 0, PAPER: 0, SCISSORS: 0 }
totals[ROCK] = sum([w for (w,o) in weighted_outcomes if o[1] == ROCK])
totals[PAPER] = sum([w for (w,o) in weighted_outcomes if o[1] == PAPER])
totals[SCISSORS] = sum([w for (w,o) in weighted_outcomes if o[1] == SCISSORS])
opponents_choice = max(totals, key=totals.get)
choice = self.__class__.BEATEN_BY[opponents_choice]
return choice
Is it better than Historical Henry
?
>>> p1 = WeightedHistoryPlayer("Forgetful Fiona")
>>> p2 = RandomHistoryPlayer("Historical Henry")
>>> play_game(p1, p2)
Forgetful Fiona vs Historical Henry
....................................................................................................
Forgetful Fiona: 43.0%
Historical Henry: 43.0%
Drawn games: 14.000000000000002%
>>>
A dead heat! How about taking on the current champion Random Rick
?
>>> p1 = WeightedHistoryPlayer("Forgetful Fiona")
>>> p2 = RandomPlayer("Random Rick")
>>> play_game(p1, p2)
Forgetful Fiona vs Random Rick
....................................................................................................
Forgetful Fiona: 35.0%
Random Rick: 38.0%
Drawn games: 27.0%
>>>
Drat! Random Rick
still retains his crown. We need something else.
Looking for patterns
There must be a pattern in Random Rick
that we can exploit. Despite his seemingly random nature, I suspect he is harbouring predictable patterns. How can we write a player to take advantage of them?
N-grams can allow us to look for recurring sequences of symbols. Think of it as a sliding window over the opponents choices. We look at n
choices, and then record what the n+1
choice was. Lets show an example using trigrams (n-grams of size 3).
Sequence of opponents moves:
ROCK ROCK PAPER SCISSORS ROCK ROCK ROCK PAPER SCISSORS ROCK ROCK PAPAER
Trigrams:
ROCK ROCK PAPER -> SCISSORS
ROCK PAPER SCISSORS -> ROCK
PAPER SCISSORS ROCK -> ROCK
SCISSORS ROCK ROCK -> ROCK
ROCK ROCK ROCK -> PAPER
ROCK ROCK PAPER -> SCISSORS
ROCK PAPER SCISSORS -> ROCK
PAPER SCISSORS ROCK -> ROCK
SCISSORS ROCK ROCK -> PAPER
To predict the next move the opponent makes given the example above, we would look at the latest trigram in the sequence - ROCK ROCK PAPER
, look it up in the list of trigrams, and see what has previously come next - SCISSORS
. We would play whatever beats that choice, in this case ROCK
.
Lets look at some code for this strategy:
class TrigramPlayer(Player):
""" Computer player that uses trigrams to look for patterns in the opponents choices. """
BEATEN_BY = {
ROCK: PAPER,
PAPER: SCISSORS,
SCISSORS: ROCK
}
def __init__(self, id):
super().__init__(id)
self.trigrams = {}
def pick(self):
import random
random.seed()
total_rounds = len(self.history)
if total_rounds < 4:
choice = random.randint(ROCK, SCISSORS)
else:
sequence = [x[1] for x in self.history]
current_trigram = tuple(sequence[-4:-1])
previous_choices = self.trigrams.get(current_trigram, [])
if len(previous_choices) > 1:
idx = random.randint(0, len(previous_choices) - 1)
choice = previous_choices[idx]
else:
choice = random.randint(ROCK, SCISSORS)
return choice
def record(self, game_details):
super().record(game_details)
round_num = len(self.history)
if round_num > 3:
sequence = [x[1] for x in self.history]
trigram = tuple(sequence[-4:-1])
choice = sequence[-1]
targets = self.trigrams.get(trigram, [])
targets.append(choice)
self.trigrams[trigram] = targets
We allow some randomness to creep in here:
- When we have more than one match for our current trigram.
- Before round 4, as there simply isn't enough data to build a trigram entry.
- If we don't find a match in the trigram dictionary.
OK, in at the deep end. How does it perform against Random Rick
?
>>> p1 = TrigramPlayer("Trigram Tracy")
>>> p2 = RandomPlayer("Random Rick")
>>> play_game(p1, p2)
Trigram Tracy vs Random Rick
....................................................................................................
Trigram Tracy: 31.0%
Random Rick: 34.0%
Drawn games: 35.0%
>>>
I give up. It seems that as far as rock, paper, scissors goes, randomness is the winning strategy.
Only human
So looking for patterns in the choices of Random Rick
failed. But can Trigram Tracy
find subconscious patterns in the pinnacle of intellect? Not ChatGPT, a human.
Let us write a human player class to interactively prompt for input.
class HumanPlayer(Player):
""" Human player at a keyboard. """
CHAR_2_INT = { "r": ROCK, "p": PAPER, "s": SCISSORS }
def pick(self):
choice = ""
while choice not in ['r', 'p', 's']:
choice = input("(r)ock, (p)aper, or (s)cissors? ")
return self.__class__.CHAR_2_INT[choice]
Lets see who will win:
>>> p1 = HumanPlayer("Human Rob")
>>> p2 = TrigramPlayer("Trigram Tracy")
>>> play_game(p1, p2)
Human Rob vs Trigram Tracy
... cut lots of prompts for choices ...
Human Rob: 32.0%
Trigram Tracy: 32.0%
Drawn games: 36.0%
Well there you have it. I'm officially as intelligent as a short Python script when it comes to playing Rock, Paper, Scissors. Hardly a ringing endorsement of our AI either.
What have we learnt
There are a lot of possible strategies for playing rock, paper, scissors. Some simple, some more complex. We have learnt that a player picking at random can defeat a more sophisticated artificial intelligence, by virtue of being unpredictable.
Out of curiosity let's ask the font of all modern knowledge - ChatGPT - what it thinks is the best strategy to adopt for playing Rock, Paper, Scissors:
The best strategy for playing Rock, Paper, Scissors against a computer opponent is to play as randomly as possible. This is because computer programs often build strategies based on your play patterns or draw techniques from a massive database of previously recorded rounds. By picking your answer blindly, you render the program’s collected patterns about your behavior useless, and should be able to tie the machine in the long run.
Sometimes being clever is over rated!
Complete program listing
#! /usr/bin/env python3
# Players.
PLAYER1 = 1
PLAYER2 = 2
# Choices.
ROCK = 3
PAPER = 4
SCISSORS = 5
# Outcomes.
WIN = 6
LOSE = 7
DRAW = 8
# All possible game states.
GAME_STATES = {
(ROCK, PAPER) : LOSE,
(ROCK, SCISSORS) : WIN,
(ROCK, ROCK) : DRAW,
(PAPER, SCISSORS) : LOSE,
(PAPER, ROCK) : WIN,
(PAPER, PAPER) : DRAW,
(SCISSORS, ROCK) : LOSE,
(SCISSORS, PAPER) : WIN,
(SCISSORS, SCISSORS) : DRAW
}
class Player:
""" Base class for a player of the game. """
def __init__(self, name):
self.name = name
self.history = []
def pick(self):
"""Return one of the three choices: ROCK, PAPER, SCISSORS."""
raise NotImplementedError()
def record(self, game_details):
"""Record the details of the round."""
self.history.append(game_details)
class AlwaysRockPlayer(Player):
""" Player who always chooses ROCK. """
def pick(self):
return ROCK
class RandomPlayer(Player):
""" Player who always makes a random choice. """
def pick(self):
import random
random.seed()
return random.randint(ROCK, SCISSORS)
class RandomHistoryPlayer(Player):
""" Player who picks at random from historical options of their opponent. """
BEATEN_BY = {
ROCK: PAPER,
PAPER: SCISSORS,
SCISSORS: ROCK
}
def pick(self):
import random
random.seed()
total_rounds = len(self.history)
if total_rounds == 0:
choice = random.randint(ROCK, SCISSORS)
else:
idx = random.randint(0, len(self.history) - 1)
opponents_choice = self.history[idx][1]
choice = self.__class__.BEATEN_BY[opponents_choice]
return choice
class WeightedHistoryPlayer(Player):
""" Computer player that linearly weights the past opponents choices. """
BEATEN_BY = {
ROCK: PAPER,
PAPER: SCISSORS,
SCISSORS: ROCK
}
def pick(self):
import random
random.seed()
total_rounds = len(self.history)
if total_rounds == 0:
choice = random.randint(ROCK, SCISSORS)
else:
weighted_outcomes = [(i / total_rounds, o) for i, o in enumerate(self.history)]
totals = { ROCK: 0, PAPER: 0, SCISSORS: 0 }
totals[ROCK] = sum([w for (w,o) in weighted_outcomes if o[1] == ROCK])
totals[PAPER] = sum([w for (w,o) in weighted_outcomes if o[1] == PAPER])
totals[SCISSORS] = sum([w for (w,o) in weighted_outcomes if o[1] == SCISSORS])
opponents_choice = max(totals, key=totals.get)
choice = self.__class__.BEATEN_BY[opponents_choice]
return choice
class TrigramPlayer(Player):
""" Computer player that uses trigrams to look for patterns in the opponents choices. """
BEATEN_BY = {
ROCK: PAPER,
PAPER: SCISSORS,
SCISSORS: ROCK
}
def __init__(self, id):
super().__init__(id)
self.trigrams = {}
def pick(self):
import random
random.seed()
total_rounds = len(self.history)
if total_rounds < 4:
choice = random.randint(ROCK, SCISSORS)
else:
sequence = [x[1] for x in self.history]
current_trigram = tuple(sequence[-4:-1])
previous_choices = self.trigrams.get(current_trigram, [])
if len(previous_choices) > 1:
idx = random.randint(0, len(previous_choices) - 1)
choice = previous_choices[idx]
else:
choice = random.randint(ROCK, SCISSORS)
return choice
def record(self, game_details):
super().record(game_details)
round_num = len(self.history)
if round_num > 3:
sequence = [x[1] for x in self.history]
trigram = tuple(sequence[-4:-1])
choice = sequence[-1]
targets = self.trigrams.get(trigram, [])
targets.append(choice)
self.trigrams[trigram] = targets
class HumanPlayer(Player):
""" Human player at a keyboard. """
CHAR_2_INT = { "r": ROCK, "p": PAPER, "s": SCISSORS }
def pick(self):
choice = ""
while choice not in ['r', 'p', 's']:
choice = input("(r)ock, (p)aper, or (s)cissors? ")
return self.__class__.CHAR_2_INT[choice]
def play_round(p1, p2):
""" Play one round of the game with the two supplied players. """
p1_choice = p1.pick()
p2_choice = p2.pick()
p1_outcome = GAME_STATES[(p1_choice, p2_choice)]
p2_outcome = GAME_STATES[(p2_choice, p1_choice)]
p1.record((p1_choice, p2_choice, p1_outcome))
p2.record((p2_choice, p1_choice, p2_outcome))
winner = 0
if p1_outcome == WIN:
winner = PLAYER1
elif p2_outcome == WIN:
winner = PLAYER2
return winner
def play_game(p1, p2, rounds=100):
""" Play several rounds of the game, reporting statistics at the end. """
print(f"{p1.name} vs {p2.name}")
results = []
for i in range(rounds):
results.append(play_round(p1, p2))
print(".", end="")
p1_total = len([x for x in results if x == PLAYER1])
p2_total = len([x for x in results if x == PLAYER2])
no_total = len([x for x in results if x == 0])
total = float(len(results))
print("")
print(f"{p1.name}: {(p1_total / total) * 100}%")
print(f"{p2.name}: {(p2_total / total) * 100}%")
print(f"Drawn games: {(no_total / total) * 100}%")
Top comments (0)