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[]
  • 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 and their_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(), or ScissorsAction()

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
      last_opponent_move = self.history[-1][1] # Find 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
      our_last_move = self.history[-1][0] # Find 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
      opponent_moves = [move[1] for move in self.history]
      
      # Count the occurrences of each move
      move_counts = {} # Count each R/P/S
      for move in opponent_moves:
          if move in move_counts:
              move_counts[move] += 1 # Add to counter
          else:
              move_counts[move] = 1 # Start counter
      
      # Find the move with the highest count
      most_common_move = None
      highest_count = 0
      for move, count in move_counts.items():
          if count > highest_count:
              most_common_move = move
              highest_count = 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:
          two_rounds_ago = self.history[-2][1]  # Opponent's action from 2 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
      opponent_moves = [move[1] for move in self.history]
      
      # Count how many times the opponent played Rock
      rock_count = sum(1 for move in opponent_moves if isinstance(move, RockAction))
      
      # 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.

If Player 1 plays Rock with probability \(r\), Paper with probability \(p\), and Scissors with probability \(s\), we have the following expected value equations for Player 2:

\(\mathbb{E}(\text{R}) = -1p + 1s\)

\(\mathbb{E}(\text{P}) = 1r - 1s\)

\(\mathbb{E}(\text{S}) = -1r + 1p\)

Since no action dominates, we know that the EV of every strategic action should be equal (since if a certain strategy was best, we’d want to always play that strategy).

To solve for \(r\), \(p\), and \(s\), we can start by setting these EVs equal:

\(\mathbb{E}(\text{R}) = \mathbb{E}(\text{P})\)

\(-1p + 1s = 1r - 1s\)

\(2s = p + r\)

Then setting these equal:

\(\mathbb{E}(\text{R}) = \mathbb{E}(\text{S})\)

\(-1p + 1s = -1r + 1p\)

\(s + r = 2p\)

And finally setting these equal:

\(\mathbb{E}(\text{P}) = \mathbb{E}(\text{S})\)

\(1r - 1s = -1r + 1p\)

\(2r = s + p\)

Now we have these equations:

\[ \begin{cases} 2s = p + r \\ s + r = 2p \\ 2r = s + p \end{cases} \]

We can rewrite the 1st:

\(r = 2s - p\)

And combine with the 2nd:

\(s + (2s - p) = 2p\)

\(3s = 3p\)

Resulting in:

\(s = p\)

Now we can go back to the 2nd equation:

\(s + r = 2p\)

And insert \(s\) = \(p\):

\(s + r = 2s\)

And arrive at:

\(r = s\)

We now see that all are equal:

\(s = p = r\)

We also know that they must all sum to \(1\):

\(r + p + s = 1\)

Since they’re all equal and sum to \(1\), we can substitute \(p\) and \(s\) with \(r\):

\(3r = 1\)

\(r = 1/3\)

So all actions are taken with probability \(1/3\):

\(r = p = s = 1/3 \quad \blacksquare\)

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:

python3 engine.py -p1 {p1_name} {p1_file_path} -p2 {p2_name} {p2_file_path} -o {output_dir} -n {n_games}"

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:

python3 engine.py -p1 'p1' players/default/ -p2 'p2' players/default/ -o p1p2test -n 1000

The output files are in the folder p1p2test:

  1. scores.p1.p2.txt contains the raw scores of each player

  2. The p1.p2 folder contains:

  • gamelog.txt: A log of all hands played

  • Other log files for each player