LESSON 24 · BEGINNER4 min read

Project: Snake, Water, Gun

Build a best-of-five Snake, Water, Gun game with pure rule functions, validated input, and tests that never depend on luck.

Python 3.12+Free to readModule 4 of 19
YOUR NEXT SMALL WINS

By the end, you’ll be able to…

  • Express the game rules as a pure function with a clear contract.
  • Inject randomness so a match can be reproduced in tests.
  • Run a bounded match with validated input and a quit command.
  • Test every outcome without playing the game manually.

01Write the rules before the interface

Snake drinks water, water douses gun, and gun shoots snake. The rules are a small decision table, not a pile of input statements. Start with a function that receives the player's move and the computer's move and returns 'win', 'lose', or 'draw'. Keep printing and input outside this function so the rules can be tested directly.

Validate the move before comparing it. A rule function should reject an unknown choice with ValueError rather than silently treating it as a loss. The error message should list the accepted moves so the caller can recover.

rules.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

02Make the computer fair and testable

random.choice gives each legal move the same chance, but a test cannot assert a specific result against an unseeded global generator. Pass a random.Random instance into the chooser, or pass the computer's move directly into the rule function. The game can still feel random while the test remains deterministic.

A seed is for reproducibility, not fairness or security. Use a dedicated Random instance rather than reseeding the module-level generator, which can surprise other code in the same process.

computer_choice.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

03Run a bounded match

A best-of-five match ends when one player reaches three wins. Draws do not add to either score. Keep the loop condition explicit: while player_score < 3 and computer_score < 3. A round counter alone is not enough unless draws are handled separately.

Read input as text, normalize it with strip().lower(), and handle the quit command before conversion. If you convert with int too early, 'quit' becomes a ValueError and the user cannot leave. Print the current score after each round so the match state is visible.

match_loop.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.

04Test the rules without playing

There are only nine combinations, so a table-driven test can cover every branch. Assert the expected result for each pair and assert that an invalid move raises ValueError. This is faster and more reliable than playing five rounds and hoping the interesting case appears.

Add one integration test for the scoring loop with a scripted sequence of moves. Keep the real input loop out of the test by extracting play_round(player, computer, scores) or by injecting a move source. The goal is confidence in the rules, not a simulation of keyboard timing. Run the rule table before the scoring test; if either fails, fix the rule function before touching the interface.

test_rules.pyPython
import unittestBEATS = {"snake": "water", "water": "gun", "gun": "snake"}def decide(player, computer):    if player not in BEATS or computer not in BEATS:        raise ValueError("choose snake, water, or gun")    if player == computer:        return "draw"    return "win" if BEATS[player] == computer else "lose"class RuleTests(unittest.TestCase):    def test_every_pair(self):        expected = {            ("snake", "water"): "win",            ("snake", "gun"): "lose",            ("water", "gun"): "win",            ("gun", "snake"): "win",        }        for pair, result in expected.items():            self.assertEqual(decide(*pair), result)        for move in BEATS:            self.assertEqual(decide(move, move), "draw")    def test_invalid_move(self):        with self.assertRaises(ValueError):            decide("fire", "water")unittest.main(argv=[""], exit=False)
LET IT CLICK

The ideas worth keeping.

  • Keep the rules pure and the interface separate.
  • Inject randomness so tests can be deterministic.
  • Bound the match and validate every move before using it.
TURN UNDERSTANDING INTO SOMETHING YOU CAN DO

Make it yours.

Type and test your code right here in the interactive workspace. Run your solution, check the output, and compare with the worked guide when ready.

01

Implement the decision table

Write decide(player, computer) using a BEATS dictionary. Return win, lose, or draw and raise ValueError for an unknown move. Demonstrate all three outcomes.

your_turn_1.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
BEATS = {"snake": "water", "water": "gun", "gun": "snake"}def decide(player, computer):    if player not in BEATS or computer not in BEATS:        raise ValueError("choose snake, water, or gun")    if player == computer:        return "draw"    return "win" if BEATS[player] == computer else "lose"print(decide("snake", "water"))print(decide("snake", "gun"))print(decide("water", "water"))

The dictionary stores only the winning relationship. A pair not found in the dictionary is a loss unless the moves are equal, which keeps the table small and easy to audit.

02

Simulate a seeded match

Use random.Random(3) to play five rounds with both moves chosen by the generator. Track wins, losses, and draws, then print the final tally.

your_turn_2.py🐍Python
Ctrl + ↵
Output
Click Run or press Ctrl+Enter
Output will appear here after you run the code.
Explore a worked solution
one_possible_solution.pyPython
import randomBEATS = {"snake": "water", "water": "gun", "gun": "snake"}MOVES = list(BEATS)rng = random.Random(3)score = {"win": 0, "lose": 0, "draw": 0}for _ in range(5):    player = rng.choice(MOVES)    computer = rng.choice(MOVES)    if player == computer:        score["draw"] += 1    elif BEATS[player] == computer:        score["win"] += 1    else:        score["lose"] += 1print(score)

A seeded generator makes the demonstration reproducible. The score dictionary counts each outcome without separate variables and makes the final tally easy to print.

A SMALL QUESTION. A USEFUL NEXT STEP.

What stayed with you?

Check the ideas while they’re fresh. Your answers stay in this page and feedback appears as you go.

A LITTLE ACTIVE RECALL0 / 3 answered
01Why should decide() not call input() or print()?
02How many wins are needed to finish a best-of-five match?
03What is the purpose of injecting a random.Random instance?
⚡CODING CHALLENGE
🔒Required to Mark Lesson Complete

Implement the decision table

Write decide(player, computer) using a BEATS dictionary. Return win, lose, or draw and raise ValueError for an unknown move. Demonstrate all three outcomes.

EXPECTED OUTPUT
decide("snake" "water")
decide("snake" "gun")
decide("water" "water")
main.pyPython 3
Ctrl + ↵
Execution Console
Click "Run & Verify" (or press Ctrl + Enter) to test your code against the challenge.

Keep exploring.

The official references behind this lesson, when you want a second angle or the full details.

MODULE 04 · PRACTICAL SELF-CHECK

Pause. Put the pieces together.

You’ve reached the end of Functions and reusable programs. Before moving on, use this task to check what you can do independently.

Separate a calculation logic from input/output display, import your function from a second file, and call it without running unwanted code on import.

Try it in your own environment and write down the result. This is a self-check; reading completion does not evaluate your code.

Review this module

One lesson closer.

Reading is a start. Trying it yourself makes the connection.

Solve the hands-on coding challenge in the knowledge section to verify your code and unlock lesson completion.