Rob Cook


Home | Donate

Rock, paper, scissors, fight!


2024-01-22

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:

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}%")
end