Rock, Paper, Scissors
January 23, 2022Why 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
beatsScissors
but loses toPaper
.Paper
beatsRock
but loses toScissors
.Scissors
beatsPaper
but loses toRock
.- Equal
Move
s 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 randomMove
.Keep
: Always keeps the lastMove
. If the lastMove
was e.g.Rock
, it will again beRock
and so on.Forward
: If you think of the possible outcomesRock
-Paper
-Scissor
as ordered, this strategy will simply select the nextMove
. If e.g. the lastMove
wasRock
, it will selectPaper
, afterPaper
it will selectScissors
, afterScissors
it starts withRock
again.
Note: The producedMove
would always win over the previous one.Backward
: LikeForward
but in the other direction.
Note: The producedMove
would always lose to the previous one.Copy
: This imitates other thePlayer
s lastMove
.Rock
: AlwaysRock
.Paper
: AlwaysPaper
.Scissors
: AlwaysScissors
.
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 (wheren
is a large number to get good statistics).- In each round, the player selects a
Move
based on theirStrategy
. - Note: the first
Move
is random (although we will change that later).
- In each round, the player selects a
- The
Statistics
class keeps track of the games' outcomes so we know whichStrategy
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 Player
s (i.e. Strategy
s) 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 Strategy
s 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 otherStrategy
s.
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
vsRock
Rock
vsPaper
Rock
vsScissors
Paper
vsRock
Paper
vsPaper
Paper
vsScissors
Scissors
vsRock
Scissors
vsPaper
Scissors
vsScissors
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
dominatesCopy
:Player
1 selects the next best move whilePlayer
2 copies whatPlayer
1 just played, so naturally it's a win forPlayer
1.Copy
dominatesBackward
:Player
1 selects whatPlayer
2 just played, whilePlayer
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 Strategy
s 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 Strategy
s
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 Strategy
s:
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 nextMove
in all cases seems to be the most successful strategy.Forward/Copy/Forward
: Interestingly, this combination of strategies is just as successful. SelectingCopy
in case of a lost round yields the same score as the plainForward
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 againstForward/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 againstCopy/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.