wolfgang ziegler


„make stuff and blog about it“

Rock, Paper, Scissors

January 23, 2022

Why Rock, Paper, Scissors?

🪨 📰 ✂️

Why this topic? Well, coincidentally, I stumbled over this twice this week.

First, I saw a video about it on Numberphile, a YouTube channel that I can highly recommend and I'am watching regularly since a friend recently told me about it.

Then, my favorite podcast Stuff You Should Know did an episode about this game. I was listening to this episode while I was on a run and during this run I "wrote" most of the C# code simulating Rock, Paper, Scissors in my head.

Of course, I then had to actually write this code down and try it out. And since I enjoyed this so much, I even wrote this blog post about it.

The Setup

All the code can be found here on GitHub. Throughout the blog post I tried to add enough snippets from the project, so that you can follow along. Note however, that these snippets are only a subset of the full project's code.

Let's start with the most important data type Move.

enum Move
{
  Rock,
  Paper,
  Scissors,
}

Rock, Paper, Scissors sketch.

  • Rock beats Scissors but loses to Paper.
  • Paper beats Rock but loses to Scissors.
  • Scissors beats Paper but loses to Rock.
  • Equal Moves are a tie.
  • ... you know the drill.

Then, let's define a couple of strategies a player can use:

enum Strategy
{
  Random,
  Keep,
  Forward,
  Backward,
  Copy,
  Rock,
  Paper,
  Scissors,
}

Here's how these strategies work.

  • Random: As the name says, a random Move.
  • Keep: Always keeps the last Move. If the last Move was e.g. Rock, it will again be Rock and so on.
  • Forward: If you think of the possible outcomes Rock - Paper - Scissor as ordered, this strategy will simply select the next Move. If e.g. the last Move was Rock, it will select Paper, after Paper it will select Scissors, after Scissors it starts with Rock again.
    Note: The produced Move would always win over the previous one.
  • Backward: Like Forward but in the other direction.
    Note: The produced Move would always lose to the previous one.
  • Copy: This imitates other the Players last Move.
  • Rock: Always Rock.
  • Paper: Always Paper.
  • Scissors: Always Scissors.

Then, we have a Player class that selects one of these strategies and can compete against another Player (in fact, we are using an interface IPlayer mostly, to keep that Player implementation interchangeable).

So here is, how the simulation goes:

  • All possible combinations of stragies are calculated (CreatePlayerTypes).
  • Each player (i.e. Strategy) competes against all other players.
  • Each game consists of n rounds (where n is a large number to get good statistics).
    • In each round, the player selects a Move based on their Strategy.
    • Note: the first Move is random (although we will change that later).
  • The Statistics class keeps track of the games' outcomes so we know which Strategy is the most successful one.

Randomness

Rock, Paper, Scissors is a game of randomness and a player selecting the Random strategy should have equal outcomes of wins, losses and ties.

Let's test this theory first. For that, we create two Player instances that use Strategy.Random and let them can compete against each other.

var player1 = new Player(Strategy.Random);
var player2 = new Player(Strategy.Random);

Calling the Play method, we let these players compete a million times (rounds). The Statistics class keeps track of all the outcomes of these games.

var statistics = new Statistics();
Play(player1, player2, 1_000_000, statistics);

Finally, we call PrintGame to get an overview of the game and all its rounds played.

statistics.PrintGame();

Yes, that output looks about right. One third of the games was won, another third was lost and the last third was ties.

--- [Random] vs [Random]
Number of games played: 1000000
  Wins: 33% (333677)
  Losses: 33% (333079)
  Ties: 33% (333244)

In fact, as soon as one of the players employs the Random strategy, this will always be the outcome, regardless of the strategy chosen by the other player.

Let's code a verification for that in the Play method.

if (player1.IsRandom || player2.IsRandom)
{
  Debug.Assert(Math.Abs(statistics.Wins - 33) <= 1);
  Debug.Assert(Math.Abs(statistics.Losses - 33) <= 1);
  Debug.Assert(Math.Abs(statistics.Ties - 33) <= 1);
}

OK, this checks out. But let's ignore Random from now on and and look for a Strategy that is more successful and yields more than 33% wins when competing against all other strategies.

Let's define "successful" as yielding more than 50% wins after playing n rounds.
Or written in code:

var successful = statistics.Wins > 50;

A Simple Player

The Statistics class also tracks the number of wins for each strategy so we can ask for a ranking (PrintRanking) after having all Players (i.e. Strategys) competed against each other.

The Simulate method takes care of that.

void Simulate(List<IPlayer> playerTypes, int rounds)
{
  var statistics = new Statistics();
  foreach (var player1 in playerTypes)
  {
    foreach (var player2 in playerTypes)
    {
      Play(player1, player2, rounds, statistics);
    }
  }
  statistics.PrintRanking();
}

Let's see how all Strategys compare against each other. Looks like Forward is most successful.

Note: The score in these final rankings means that a Strategy lead to more than 50% percent of won rounds in a game. Put differently, a score of e.g. 2 means that a given strategy was able to beat 2 other Strategys.

Top 5 strategies:
  Forward - score: 2
  Keep - score: 1
  Copy - score: 1
  Rock - score: 1
  Paper - score: 1

So, let's run the simulation again and this time the result looks like this.

  Rock - score: 2
  Forward - score: 1
  Copy - score: 1
  Paper - score: 1
  Scissors - score: 1

Apparently, a lot depends on the initial random move here. So let's get rid of that randomness.

Removing Randomness

Instead of starting the first round with a randomly selected Move, we actually run the simulation with all possible start combinations (calling the method PlayWithStartMoves).

This means we have 9 different constellations for the initial round (and we play all of them).

  • Rock vs Rock
  • Rock vs Paper
  • Rock vs Scissors
  • Paper vs Rock
  • Paper vs Paper
  • Paper vs Scissors
  • Scissors vs Rock
  • Scissors vs Paper
  • Scissors vs Scissors

Running the simulation again, this now gives us a consistent results.

--- [Forward] vs [Copy]
Number of games played: 900000
  Wins: 99% (899994)
  Losses: 0% (3)
  Ties: 0% (3)

--- [Copy] vs [Backward]
Number of games played: 900000
  Wins: 99% (899994)
  Losses: 0% (3)
  Ties: 0% (3)

Most successful strategies:
  Forward - score: 1
  Copy - score: 1
  • Forward dominates Copy: Player 1 selects the next best move while Player 2 copies what Player 1 just played, so naturally it's a win for Player 1.
  • Copy dominates Backward: Player 1 selects what Player 2 just played, while Player 2 goes "backwards" and selects the inferior move. Surely, Player 1 will always win here.

So far - so boring, admittedly. The game dynamics are just too limited to produce interesting results. So let's improve our Player class.

An Improved Player

In fact, we introduce a new class ImprovedPlayer that implements the same interface IPlayer as the Player class so we can use instances of these classes interchangeably.

The main difference in this new class is that it holds 3 Strategys instead of just one and it applies a different Strategy whether the previous round was won, lost or tied.

public ImprovedPlayer(Strategy winStrategy, Strategy loseStrategy, Strategy tieStrategy) 
{
  _winStrategy = winStrategy;
  _loseStrategy = loseStrategy;
  _tieStrategy = tieStrategy;
}

Based on the outcome of the last round, this ImprovedPlayer selects one of these 3 Strategys when the NextMove method is called.

public Move NextMove(Result lastResult, Move lastMove, Move lastMoveOtherPlayer)
{
  var strategy = lastResult switch
  {
    Result.Win => _winStrategy,
    Result.Lose => _loseStrategy,
    Result.Tie => _tieStrategy,
  };
  return Player.GetNextMoveForStrategy(strategy, lastMove, lastMoveOtherPlayer);
}

Using the same start conditions (randomness removed), this yields two dominating Strategys:

Most successful strategies:
  Forward/Forward/Forward - score: 132
  Forward/Copy/Forward - score: 132
  Keep/Forward/Forward - score: 111
  Keep/Copy/Forward - score: 111
  Forward/Forward/Rock - score: 111
  • Forward/Forward/Forward: So again, just selecting the next Move in all cases seems to be the most successful strategy.
  • Forward/Copy/Forward: Interestingly, this combination of strategies is just as successful. Selecting Copy in case of a lost round yields the same score as the plain Forward strategy.

Let's see how these two strategies compare against each other:

--- [Forward/Forward/Forward] vs [Forward/Copy/Forward]
Number of games played: 9000
  Wins: 33% (3000)
  Losses: 33% (3000)
  Ties: 33% (3000)

As expected, there's no advantage for either of those.

But why is Forward/Copy/Forward more successful than e.g. Copy/Forward/Forward or Forward/Forward/Copy? Let's have a closer look at how these strategies perform against all other combinations and more specifically: which (and how many) others they can defeat. The method CompareImproved takes care of that.

Note: I removed the "trivial" (Rock, Paper, Scissors) strategies for this since it does not change the overall result but makes it easier to grasp since it reduces the number of combinations we have to look at.

This is the result of comparing Forward/Copy/Forward to Forward/Forward/Copy:

Note that we look only at unique wins here, i.e. we exclude losing strategies that both Forward/Copy/Forward and Forward/Forward/Copy have defeated.

Only [Forward/Copy/Forward] wins against:
- Keep/Forward/Keep
- Keep/Forward/Copy
- Keep/Copy/Keep
- Keep/Copy/Copy
- Forward/Forward/Keep
- Forward/Forward/Copy
- Forward/Copy/Keep
- Forward/Copy/Copy
Only [Forward/Forward/Copy] wins against:
 - Keep/Forward/Backward
 - Keep/Copy/Backward
 - Forward/Forward/Backward
 - Forward/Copy/Backward

We can immediately see that:

  • Forward/Forward/Copy has more unique wins.
  • Forward/Copy/Forward wins directly against Forward/Forward/Copy.

Comparing Forward/Copy/Forward and Copy/Forward/Forward, the result is even more onesided:

Only [Forward/Copy/Forward] wins against:
- Keep/Forward/Keep
- Keep/Forward/Copy
- Keep/Copy/Keep
- Keep/Copy/Copy
- Forward/Forward/Keep
- Forward/Forward/Copy
- Forward/Copy/Keep
- Forward/Copy/Copy
- Backward/Forward/Keep
- Backward/Forward/Forward
- Backward/Forward/Backward
- Backward/Forward/Copy
- Backward/Copy/Keep
- Backward/Copy/Forward
- Backward/Copy/Backward
- Backward/Copy/Copy
- Copy/Forward/Keep
- Copy/Forward/Forward
- Copy/Forward/Backward
- Copy/Forward/Copy
- Copy/Copy/Keep
- Copy/Copy/Forward
- Copy/Copy/Backward
- Copy/Copy/Copy
Only [Copy/Forward/Forward] wins against:
- Keep/Backward/Keep
- Keep/Backward/Copy
- Forward/Backward/Keep
- Forward/Backward/Copy
- Backward/Backward/Keep
- Backward/Backward/Forward
- Backward/Backward/Backward
- Backward/Backward/Copy
- Copy/Backward/Keep
- Copy/Backward/Forward
- Copy/Backward/Backward
- Copy/Backward/Copy

Again, we see that:

  • Forward/Forward/Copy has more unique wins.
  • Forward/Copy/Forward wins directly against Copy/Forward/Forward.

So, after all, the Forward Strategy seems to be the best bet as soon as a player deviates from employing pure randomness (which against a human opponent is always the case). This underlines the psychological, and game-theoretical aspect here. Because, what happens if every player knows that using Forward gives and advantage? Right, they will chose a strategy that counters a Forward move. Which in turn leads to another strategy that counters that counter-move. And so on, and so forth ... and endless game of counter-moves until someone "chickens" out. You see, we actually just scratched the very surface of tactics so far.

Next?

It's fascinating how deep into a rabbit-hole one can go with such a simple game as Rock, Paper, Scissors. But I guess, that is part of the fascination with such simple setups (similar to fractals, cellular automata, ...) to see how far one can go from there.

Of course, even though this was quite a lengthy post already, we only scratched the surface of this topic. The next step would be more advanced strategies that e.g.

  • have a longer "memory" and act accordingly
  • change their strategy depending on the course of the game (e.g. adapt to losing or tie streaks).
  • use machine learning to try and figure out the opponent's strategy. In fact, I wrote a very simple neural network (DotNeuralNet) a while ago, that I wanted to port to .NET Core anyway. This will be the perfect opportunity to dust off this project and put it to use again. So stay tuned for a follow-up post on this using a SmartPlayer strategy.