Rock Paper Scissors Game
Abstract
Section titled “Abstract”Rock Paper Scissors is one of the oldest decision games still in play — and one of the best for teaching beginners how to model rules in code. In this project you will build a command-line version of the game where you play against the computer. Along the way you will learn how to pick a random item from a list, validate user input, model game rules cleanly (avoiding huge if/elif chains), and grow the program with score keeping, replay, and even a basic AI opponent.
You will leave this tutorial comfortable with:
random.choice()for picking from a list.- Loops with input validation.
- Dictionary-driven rules instead of chained conditionals.
- Counting wins / losses / ties across rounds.
- The Markov-chain trick that lets an AI start beating humans.
The Rules
Section titled “The Rules”- Rock crushes Scissors.
- Scissors cut Paper.
- Paper covers Rock.
- Same choice → tie.
That is the entire game. The challenge is encoding those rules so the program is readable and easy to extend (Rock-Paper-Scissors-Lizard-Spock anyone?).
Prerequisites
Section titled “Prerequisites”- Python 3.6 or above.
- A text editor or IDE (VS Code recommended).
- Comfort running a
.pyfile from the terminal. - Familiarity with
if/elif/elseandinput().
Concepts You Will Use
Section titled “Concepts You Will Use”| Concept | Purpose |
|---|---|
random.choice(list) | Pick a random element from a sequence. |
.upper() / .lower() | Normalize user input so case does not matter. |
while True + break | Replay loop until the user quits. |
| Dictionary lookups | Replace long if/elif chains with a clean data structure. |
| F-strings | Format output cleanly. |
Getting Started
Section titled “Getting Started”Create the project
Section titled “Create the project”- Make a folder named
rockpaperscissors-game. - Inside it, create
rockpaperscissors.py. - Open the folder in your editor.
Write the code
Section titled “Write the code”Rock Paper Scissors
pch.viewSource"""Rock-Paper-Scissors, up to Lizard-Spock, with an AI that is worth beating.
The rules live in one dictionary rather than an `elif` ladder. That is not
tidiness for its own sake: adding Lizard and Spock takes the ladder from 3
branches to 10, and takes the dictionary from 3 lines to 5.
python rockpaperscissors.py # play; unattended it runs the demo
python rockpaperscissors.py --demo # AI vs. two scripted opponents
python rockpaperscissors.py --test # unit tests
"""
import collections
import random
import sys
# Each move maps to everything it beats. Three-move play uses the first
# entry of each list; the full five-move game uses both.
BEATS = {
"ROCK": ["SCISSORS", "LIZARD"],
"PAPER": ["ROCK", "SPOCK"],
"SCISSORS": ["PAPER", "LIZARD"],
"LIZARD": ["PAPER", "SPOCK"],
"SPOCK": ["SCISSORS", "ROCK"],
}
CLASSIC = ["ROCK", "PAPER", "SCISSORS"]
options = CLASSIC # what an unqualified game uses
def winner(user: str, computer: str) -> str:
"""Who won: "user", "computer", or "tie"."""
if user == computer:
return "tie"
return "user" if computer in BEATS[user] else "computer"
COUNTER = {"ROCK": "PAPER", "PAPER": "SCISSORS", "SCISSORS": "ROCK",
"LIZARD": "ROCK", "SPOCK": "LIZARD"}
last_user_move = None
def ai_pick(moves=None) -> str:
"""Counter whatever the player threw last time.
This is a cheap strategy and it works, because people repeat moves far
more often than chance would. Against a genuinely random opponent it is
worth nothing -- which the demo below measures rather than asserts.
"""
moves = moves or options
if last_user_move is None:
return random.choice(moves)
return COUNTER.get(last_user_move, random.choice(moves))
score = {"user": 0, "computer": 0, "tie": 0}
def ask(prompt, default=""):
"""Read a line, or fall back to `default` when nobody is there to type."""
try:
answer = input(prompt)
except EOFError:
print(f"{default} (no input available, using the default)")
return default
return answer.strip() or default
def play_round(user_choice=None, moves=None, smart=True) -> str:
"""One round: pick, compare, score. Returns the result."""
global last_user_move
moves = moves or options
if user_choice is None:
user_choice = ask(f"Choose {', '.join(m.title() for m in moves)}: ",
moves[0]).upper()
while user_choice not in moves:
user_choice = ask("Invalid input. Try again: ",
moves[0]).upper()
computer_choice = ai_pick(moves) if smart else random.choice(moves)
result = winner(user_choice, computer_choice)
score[result] += 1
verdict = {"tie": "It's a tie!", "user": "You win!",
"computer": "You lose!"}[result]
print(f"Computer chose {computer_choice}. {verdict}")
print(f"Score -- You: {score['user']} Computer: {score['computer']} "
f"Ties: {score['tie']}")
last_user_move = user_choice
return result
def play():
"""The interactive loop."""
while True:
play_round()
if ask("Do you want to play again? (y/n): ", "n").lower() != "y":
break
print("Thanks for playing!")
def measure(strategy, rounds=30_000, smart=True) -> dict:
"""How often the counter-AI wins against a given player, over many rounds.
`strategy` is a function taking the round number and returning a move.
"""
global last_user_move
last_user_move = None
tally = collections.Counter()
for i in range(rounds):
user_choice = strategy(i)
computer_choice = ai_pick(CLASSIC) if smart \
else random.choice(CLASSIC)
tally[winner(user_choice, computer_choice)] += 1
last_user_move = user_choice
return {key: tally[key] / rounds for key in ("user", "computer", "tie")}
def demo():
"""Measure the AI against a random player and a habitual one."""
random.seed(20260809)
print(f"{'opponent':34} {'AI wins':>8} {'player wins':>12} {'ties':>7}")
print("-" * 64)
cases = [
("uniformly random player",
lambda i: random.choice(CLASSIC), True),
("player who repeats one move",
lambda i: "ROCK", True),
("player cycling R-P-S",
lambda i: CLASSIC[i % 3], True),
("random player, AI also random",
lambda i: random.choice(CLASSIC), False),
]
for label, strategy, smart in cases:
rates = measure(strategy, smart=smart)
print(f"{label:34} {rates['computer']:7.1%} {rates['user']:11.1%} "
f"{rates['tie']:6.1%}")
print("\nThe counter strategy is only worth anything against a player who")
print("repeats. Against a random opponent it lands on the same 1/3 as")
print("random play, because there is nothing left to predict.")
if __name__ == "__main__":
if "--test" in sys.argv:
import unittest
class TestGame(unittest.TestCase):
def test_rock_beats_scissors(self):
self.assertEqual(winner("ROCK", "SCISSORS"), "user")
def test_same_move_ties(self):
for move in BEATS:
self.assertEqual(winner(move, move), "tie")
def test_every_pairing_has_a_winner(self):
for a in BEATS:
for b in BEATS:
result = winner(a, b)
if a == b:
continue
# Exactly one direction wins; the rules must not be
# symmetric or a move would beat what beats it.
self.assertNotEqual(result, winner(b, a))
def test_each_move_beats_two_and_loses_to_two(self):
for move in BEATS:
wins = sum(1 for other in BEATS
if other != move and winner(move, other)
== "user")
self.assertEqual(wins, 2)
unittest.main(argv=sys.argv[:1], exit=False)
elif "--demo" in sys.argv or not sys.stdin.isatty():
demo()
else:
play() Save the file, open a terminal in the folder, run:
C:\Users\username\PythonCentralHub\projects\beginners\rockpaperscissorsgame> python rockpaperscissors.py
Choose Rock, Paper or Scissors: Rock
Computer chose SCISSORS. You win!
Do you want to play again? (y/n): y
Choose Rock, Paper or Scissors: paper
Computer chose PAPER. It's a tie!
Do you want to play again? (y/n): n
Thanks for playing!What it produces
Section titled “What it produces”Running the file exactly as it ships takes 0.1 s and prints:
Choose Rock, Paper, Scissors: ROCK (no input available, using the default)
Computer chose PAPER. You lose!
Score -- You: 0 Computer: 1 Ties: 0
Do you want to play again? (y/n): n (no input available, using the default)
Thanks for playing!How it fits together
Section titled “How it fits together”Read from the top: this is what runs when you execute the file, and which function calls which. It is generated from the code, so it cannot drift from it.
flowchart TD
RUN(["python rockpaperscissors.py"])
ask("ask")
play("play")
RUN --> ask
play --> ask
Step-by-Step Explanation
Section titled “Step-by-Step Explanation”1. Import randomness
Section titled “1. Import randomness”import randomrandom.choice([...]) picks one item from a list uniformly at random.
2. Build the option list
Section titled “2. Build the option list”options = ["ROCK", "PAPER", "SCISSORS"]Capitalize all entries up front. Then normalize the user’s input to uppercase too — comparisons become trivial.
3. Read the player’s choice
Section titled “3. Read the player’s choice”user_choice = input("Choose Rock, Paper or Scissors: ").upper()
while user_choice not in options:
user_choice = input("Invalid input. Choose Rock, Paper or Scissors: ").upper()The while loop is a validation loop. It refuses to move on until the user types something valid. No try/except needed because we are only checking membership in a list.
4. Computer picks a choice
Section titled “4. Computer picks a choice”computer_choice = random.choice(options)5. Decide the winner
Section titled “5. Decide the winner”if user_choice == computer_choice:
print(f"Computer chose {computer_choice}. It's a tie!")
elif (user_choice == "ROCK" and computer_choice == "SCISSORS") \
or (user_choice == "PAPER" and computer_choice == "ROCK") \
or (user_choice == "SCISSORS" and computer_choice == "PAPER"):
print(f"Computer chose {computer_choice}. You win!")
else:
print(f"Computer chose {computer_choice}. You lose!")That elif is doing a lot. The next section shows a cleaner way.
6. Replay loop
Section titled “6. Replay loop”while True:
play()
if input("Do you want to play again? (y/n): ").lower() != "y":
print("Thanks for playing!")
breakCleaner Rules with a Dictionary
Section titled “Cleaner Rules with a Dictionary”A growing elif chain is a smell. Replace it with a dictionary that maps each choice to “what it beats”:
BEATS = {
"ROCK": "SCISSORS",
"SCISSORS": "PAPER",
"PAPER": "ROCK",
}
def winner(user, computer):
if user == computer:
return "tie"
return "user" if BEATS[user] == computer else "computer"Reading top to bottom: “Rock beats Scissors. Scissors beat Paper. Paper beats Rock.” Adding a new option later (e.g., Lizard, Spock) means one more line per option, not a combinatorial explosion of elifs.
Bonus: Rock-Paper-Scissors-Lizard-Spock
Section titled “Bonus: Rock-Paper-Scissors-Lizard-Spock”BEATS = {
"ROCK": ["SCISSORS", "LIZARD"],
"PAPER": ["ROCK", "SPOCK"],
"SCISSORS": ["PAPER", "LIZARD"],
"LIZARD": ["PAPER", "SPOCK"],
"SPOCK": ["SCISSORS", "ROCK"],
}
def winner(user, computer):
if user == computer:
return "tie"
return "user" if computer in BEATS[user] else "computer"Five-option variant from The Big Bang Theory. Same code shape, more fun.
Add Score Tracking
Section titled “Add Score Tracking”score = {"user": 0, "computer": 0, "tie": 0}
def play_round():
# … existing logic …
result = winner(user_choice, computer_choice)
score[result] += 1
print(f"Score — You: {score['user']} Computer: {score['computer']} Ties: {score['tie']}")After each round you see the running totals. When the user quits, print a final summary.
A Smarter Computer
Section titled “A Smarter Computer”Random play means the computer wins 33 % of the time. Humans have patterns. A simple Markov-chain AI tracks what the player typed last round and bets on the same again:
COUNTER = {"ROCK": "PAPER", "PAPER": "SCISSORS", "SCISSORS": "ROCK"}
last_user_move = None
def ai_pick():
global last_user_move
if last_user_move is None:
return random.choice(options)
return COUNTER[last_user_move] # counter the player's previous move
# after the round:
last_user_move = user_choiceNaive but surprisingly effective — most beginners do repeat their previous move. For a real upgrade, track the transition matrix (what move follows what) and predict accordingly.
Whether that counts as smart depends entirely on who is playing. The file measures it over 30,000 rounds against three scripted opponents:
opponent AI wins player wins ties
uniformly random player 33.5% 33.0% 33.5%
player who repeats one move 100.0% 0.0% 0.0%
player cycling R-P-S 0.0% 0.0% 100.0%Against someone who always throws Rock it wins every single round. Against a random opponent it wins 33.5%, which is the same one-third random play gets — there is no pattern to counter, so the strategy is worth exactly nothing.
The third row is the one worth sitting with. Against a player cycling Rock-Paper-Scissors, the AI ties 100% of the time, and it does so deterministically: countering Rock means playing Paper, which is precisely what the cycling player throws next. A predictable counter-strategy is itself a pattern, and a slightly cleverer opponent would beat it every round by playing the counter to the counter. This is why competitive rock-paper-scissors bots track several moves of history and mix in randomness rather than committing to one rule.
Common Mistakes
Section titled “Common Mistakes”| Problem | Cause | Fix |
|---|---|---|
User typing rock is rejected | Forgot .upper() | Normalize both sides to one case |
| Always loses to Scissors | Typo in the rules table | Compare against a single dictionary |
| Loop never exits | Wrong indentation of break | Make sure break is inside the if |
| Score persists between runs but should reset | Globals are reused on replay | Reset score inside the replay loop |
Variations to Try
Section titled “Variations to Try”1. Best of N rounds
Section titled “1. Best of N rounds”First to 3 wins takes the match:
while score["user"] < 3 and score["computer"] < 3:
play_round()
print("You won the match!" if score["user"] == 3 else "Computer wins the match.")2. Two human players
Section titled “2. Two human players”Ask each player privately for their move (clear the terminal between turns).
3. Animated countdown
Section titled “3. Animated countdown”Use time.sleep and print("...", end="\r") to print “Rock… Paper… Scissors!”.
4. GUI version
Section titled “4. GUI version”Tkinter with three big buttons. The label shows the result.
5. Network play
Section titled “5. Network play”Build a simple Flask backend (see Basic Web Server) so two players in different places submit moves and the server decides the winner.
6. Hand-gesture version
Section titled “6. Hand-gesture version”Use OpenCV to detect the user’s hand pose with a webcam. See Gesture Recognition System for the techniques.
7. Statistics dashboard
Section titled “7. Statistics dashboard”After 100 rounds, plot the user’s move distribution. Are you secretly a “rock-loving” player?
Real-World Applications
Section titled “Real-World Applications”- Tutorial for finite-state game logic.
- Demonstrations of probability and game-theory mixed strategies.
- Onboarding example for new game programmers.
- A great first project for teaching simple AI / pattern detection.
Best Practices Demonstrated
Section titled “Best Practices Demonstrated”- Normalize input early so all comparisons assume one form.
- Encode rules as data (a dictionary) instead of code (a chain of
ifs). - Separate the round from the match —
play_round()andplay_match()should be different functions. - Track state explicitly — score in a dictionary, not three scattered variables.
Educational Value
Section titled “Educational Value”This project teaches:
- Game loops — round, match, replay.
- Data-driven rules — the heart of every config-driven system.
- Input validation — the
while not validpattern. - Random vs. deterministic AI — a fast intro to game-playing agents.
Next Steps
Section titled “Next Steps”- Add score-based difficulty: after 5 losses, the AI eases off.
- Implement tournament mode with brackets.
- Wrap state in a
Gameclass to learn OOP. See the Hangman project for a similar refactor. - Build a web version that two players in different browsers can join.
Try it here
Section titled “Try it here”Click your move — the computer picks at random. Paper beats rock, scissors beats paper, rock beats scissors:
Conclusion
Section titled “Conclusion”You wrote a complete game, refactored its rules into a clean data structure, added score keeping, and even gave the computer a tiny brain. The same patterns — validation loops, dictionary-driven rules, score state — turn up in every game and many non-game projects. Full source on GitHub. Find more beginner projects on Python Central Hub.
Pitfalls
Section titled “Pitfalls”- The win conditions are three separate
elifbranches. Rock beats scissors, paper beats rock, scissors beats paper — written out one at a time. The whole game is(user - computer) % 3 == 1once the options are indexed, and the exercise below shows both agreeing on all nine outcomes. - Validation happens after the first read, not before. The code takes a choice, then enters a
whileloop to re-ask if it was invalid. Reading inside the loop from the start removes the duplicated prompt. .upper()is called four times on the same value. It is uppercased when read, then again in every comparison. Normalising once at the point of input is the fix, and it is where every case-sensitivity bug comes from.random.choicemakes the opponent uniform, not unbeatable. A human plays scissors less often than chance; an opponent that tracked your history would win more than a third of the time. Uniform is the fair strategy, not the strong one.
- One round runs in 0.102 s and terminates unattended, because
ask()answersnwhen nothing is typed. - Nine outcomes: three ties, three wins, three losses.
- The three
elifwin conditions collapse to(user - computer) % 3 == 1when the options are indexed. - Validation re-asks after the first read rather than looping from the start, so the prompt is written twice.
- A uniform opponent is fair, not strong — it cannot exploit a human’s bias away from scissors.
Try it yourself
Section titled “Try it yourself”-
The game has three `elif` branches for the win cases. What single expression replaces them?
pch.quizShowAnswer
B — (user - computer) % 3 == 1, once ROCK, PAPER and SCISSORS are indexed 0, 1, 2 — each option beats the one before it, cyclically
-
`random.choice(options)` picks uniformly. Does that make the computer hard to beat?
pch.quizShowAnswer
B — It makes it unexploitable but not strong. Uniform play wins exactly a third against anyone; an opponent that tracked your history would beat a human, who plays scissors less often than chance
-
Why does the program produce a full round when run with no input at all?
pch.quizShowAnswer
B — `ask()` returns its stated default when stdin is closed — Rock for the choice, n for the replay — so the round plays out and the loop exits
pch.coffeeTagline
pch.coffeeCtapch.feedbackHeading
pch.feedbackSubheading