RPS Hackathon @ Fractal: Guide to Building a Bot
Starter Code
We provide Python starter code that you can submit directly on the Submission page or that you can download and submit through file upload.
We provide code that by default plays randomly. The random strategy guarantees a breakeven result, which won’t be good enough to win because it won’t take advantage of the suboptimal environment bots.
Variables
The profit so far in the match is stored in:
self.my_profit
And the history of your actions and your opponent actions are stored in the following array, where each history is a tuple of (my_action, their_action)
.
self.history[]
Functions
def __init__(self)
:- Initializes profit
self.my_profit
- Initializes the history array
self.history[]
- Initializes profit
handle_results(self, *, my_action, their_action, my_payoff, match_clock)
:- This is run at the end of every game
- It updates the history with
my_action
andtheir_action
- It updates the payoff with
my_payoff
match_clock
can be used to see how much of your 30 second total time is remaining (this can generally be ignored)
def get_action(self, *, match_clock)
:- This is the main function where you run your strategy and return an action
- You can return
RockAction()
,PaperAction()
, orScissorsAction()
Problem Solving and Strategy
The goal of the hackathon is to figure out how to adapt to your opponents (other participants and environment bots)!
A few possible strategies are:
- Single out specific bots that are exploitable and write code to identify and exploit them
- Build an ensemble of exploits targeting many opponents
- Attempt to predict the next move of each bot using a general algorithm
- Use random play as a backup plan (see below for a full explanation of why this Nash equilibrium strategy always breaks even)
LLMs
We welcome you to use LLMs like Claude or ChatGPT. You can provide the starter code/code samples and get their help with implementing better strategies.
Writing Your Own Bot
Your bot will be facing off against a variety of participant bots and environment bots. Here we illustrate some examples of how to write more complex bots. The main function to modify is get_action
, which is the one that returns the move for each game.
Win vs. Last Action
This bot plays to beat the opponent’s last action.
def get_action(self, *, match_clock):
if self.history: # After the 1st game
= self.history[-1][1] # Find last opponent move
last_opponent_move if isinstance(last_opponent_move, RockAction): # If Rock
return PaperAction() # Play Paper
elif isinstance(last_opponent_move, PaperAction): # If Paper
return ScissorsAction() # Play Scissors
else: # If Scissors
return RockAction() # Play Rock
else: # 1st game play randomly
return random.choice([RockAction(), PaperAction(), ScissorsAction()])
Win vs. Opponent Playing “Win vs. Last Action”
This bot plays to beat the opponent playing the above “win vs. last action” strategy.
If our last action is Rock, they would play Paper, so we should play Scissors.
If our last action is Paper, they would play Scissors, so we should play Rock.
If our last action is Scissors, they would play Rock, so we should play Paper.
def get_action(self, *, match_clock):
if self.history: # After the 1st game
= self.history[-1][0] # Find our last move
our_last_move if isinstance(our_last_move, RockAction): # If Rock
return ScissorsAction() # Play Scissors
elif isinstance(our_last_move, PaperAction): # If Paper
return RockAction() # Play Rock
else: # If Scissors
return PaperAction() # Play Paper
else: # 1st game play randomly
return random.choice([RockAction(), PaperAction(), ScissorsAction()])
Win vs. Full Opponent Most Frequent
Instead of just looking at the last action, this bot plays to beat the most frequent opponent action over the entire history.
def get_action(self, *, match_clock):
if self.history: # After the 1st game
= [move[1] for move in self.history]
opponent_moves
# Count the occurrences of each move
= {} # Count each R/P/S
move_counts for move in opponent_moves:
if move in move_counts:
+= 1 # Add to counter
move_counts[move] else:
= 1 # Start counter
move_counts[move]
# Find the move with the highest count
= None
most_common_move = 0
highest_count for move, count in move_counts.items():
if count > highest_count:
= move
most_common_move = count
highest_count
if isinstance(most_common_move, RockAction):
return PaperAction()
elif isinstance(most_common_move, PaperAction):
return ScissorsAction()
else: # most_common_move is ScissorsAction
return RockAction()
else: # 1st game play randomly
return random.choice([RockAction(), PaperAction(), ScissorsAction()])
Sometimes Playing Randomly
Since there are many suboptimal environment bots, breaking even by playing randomly won’t be a good enough overall strategy, but could be valuable to consider in some situations. Here is an example of using random play in combination with another strategy.
One of the bots above is Win vs. Last Action. Suppose that you have a theory that you should actually play to beat the action that they played two games ago.
You could try something like this:
- If you’re winning or tying, then play to beat the action from 2 games ago 80% of the time and play randomly 20% of the time so that you aren’t too predictable
- If you’re losing (i.e. maybe this strategy is not working well), then always play randomly
def get_action(self, *, match_clock):
# If losing, play randomly
if self.my_profit < 0:
return random.choice([RockAction(), PaperAction(), ScissorsAction()])
# If winning or tying, and there is at least 2 rounds of history
if len(self.history) >= 2:
# 80% chance to play based on opponent's action from 2 rounds ago
if random.random() < 0.8:
= self.history[-2][1] # Opponent's action from 2 rounds ago
two_rounds_ago if isinstance(two_rounds_ago, RockAction):
return PaperAction()
elif isinstance(two_rounds_ago, PaperAction):
return ScissorsAction()
else: # ScissorsAction
return RockAction()
# In all other cases (including 1st 2 rounds), play randomly
return random.choice([RockAction(), PaperAction(), ScissorsAction()])
Exploiting a Weak Bot
In practice in this hackathon, a lot of value will come from exploiting weak bots or other opponents.
For example, you could check to see if any bot is always playing Rock. If so, you always play Paper. If not, you play randomly. Then you will breakeven against everyone, but crush the Rock-only bot.
Here’s how to execute this strategy:
def get_action(self, *, match_clock):
if self.history: # Check if the opponent has played at least once
# Get all of the opponent's moves
= [move[1] for move in self.history]
opponent_moves
# Count how many times the opponent played Rock
= sum(1 for move in opponent_moves if isinstance(move, RockAction))
rock_count
# If opponent has only played Rock, we play Paper
if rock_count == len(opponent_moves):
return PaperAction()
# If it's the first move or opponent hasn't only played Rock, play randomly
return random.choice([RockAction(), PaperAction(), ScissorsAction()])
Exploiting a Weak Bart
Game Theory Equilibrium
RPS is a zero-sum game and the payouts are symmetrical as follows:
Player 1/2 | Rock | Paper | Scissors |
---|---|---|---|
Rock | (0, 0) | (-1, 1) | (1, -1) |
Paper | (1, -1) | (0, 0) | (-1, 1) |
Scissors | (-1, 1) | (1, -1) | (0, 0) |
The Nash Equilibrium strategy is to play each action \(r = p = s = 1/3\) of the time.
Playing this strategy means that whatever your opponent does, you will breakeven! For example, think about an opponent that always plays Rock.
\[ \begin{equation} \begin{split} \mathbb{E}(\text{Equilibrium vs. Rock}) &= r*0 + p*1 + s*-1 \\ &= 1/3*0 + 1/3*1 + 1/3*-1 \\ &= 0 \end{split} \end{equation} \]
Submitting Bots
To get started, click the Login button to the left and then go to Submit Your Bot. On the bot submission page, it’s possible to submit with:
- Code text boxes directly on the webpage
- File upload a single
player.py
Python file
Game Engine
If you would like to clone the repository with the game engine, you can find it at https://github.com/pokercamp/rps-engine.
In players/default
, the player.py
file is where you write your bot. We don’t recommend changing any other files.
The engine is in engine.py
. You can use engine.py
to run two bots against each other. You can use this to test your bot against itself or other bots that you create.
The following code will run n_games
between p1_name
and p2_name
and output the result to the specified output_dir
.
The generic usage is:
-p1 {p1_name} {p1_file_path} -p2 {p2_name} {p2_file_path} -o {output_dir} -n {n_games}" python3 engine.py
For example, to run a 1000 game match with two bots that are named p1
and p2
with files in the players/default/
folder and outputted to the p1p2test
folder, do this:
-p1 'p1' players/default/ -p2 'p2' players/default/ -o p1p2test -n 1000 python3 engine.py
The output files are in the folder p1p2test
:
scores.p1.p2.txt
contains the raw scores of each playerThe
p1.p2
folder contains:
gamelog.txt
: A log of all hands playedOther log files for each player