NOETRION

Choose a topic

DAY 29 OF 30 · BUILD AND DEMONSTRATE

Project: rock, paper, scissors

Separate game rules from random choice and presentation, then test every outcome deterministically.

90–120 minutes suggested study timePython 3.12Not yet completed

Today's outcomes

  • Implement and test a pure outcome function.
  • Track rounds and scores without counting invalid input.
  • Keep seeded test randomness separate from secret generation.

Before you start: Complete Day 28 and its practical challenge.

Understand the concept

Separate game rules from the command line

Rock, paper, scissors has a small enough rule space to test completely. Three player choices and three computer choices create nine ordered combinations. Ties occur when choices match. Rock beats scissors, scissors beats paper, and paper beats rock. Store those relationships explicitly rather than scattering conditions through an input loop.

An outcome function should accept normalized choices and return win, loss, or tie from the player's perspective. It should not ask for input, print a menu, or generate the computer's choice. This pure decision function is easy to test because the same pair always has the same result.

Normalize the user's text at the interaction boundary with strip and lower. The core contract accepts only the exact lowercase names. That gives each layer one responsibility: the interface handles convenient spelling, while the rules reject invalid values rather than manufacturing an outcome.

Use randomness suitable for a game

The random module is appropriate here because the computer's choice is part of an ordinary practice game, not a secret credential. random.choice chooses an element from a sequence. A random.Random(seed) instance can create a repeatable sequence for a demonstration or test. That repeatability was a reason to avoid this generator for yesterday's password material.

Keep random choice outside the outcome function. A test can then supply scissors directly and verify that rock wins without depending on a seed or a particular sequence of random outputs. Reproducibility helps debugging, but an exhaustive rules test is stronger evidence for game logic than observing a few random rounds.

Update state only after a valid round

Track wins, losses, and ties separately. The number of played rounds should equal their sum. Invalid user input is not a round. A quit command is not a round either. Convert and validate before changing counters so a rejected input cannot silently affect the score.

The game loop should clearly display the player's choice, the computer's choice, and the result. After quit, print a final summary. An empty session is valid and has zero in every counter; do not divide by zero to calculate a win rate. A tie is a played round, so include it in the denominator if you later define a rate over all rounds.

Worked example

beats = {"rock": "scissors", "paper": "rock", "scissors": "paper"}
player, computer = "rock", "scissors"
if player == computer:
    result = "tie"
elif beats[player] == computer:
    result = "win"
else:
    result = "loss"
assert result == "win"
score = {"win": 0, "loss": 0, "tie": 0}
score[result] += 1
assert sum(score.values()) == 1
print(player, "vs", computer, "->", result)
Player / Computer Rock Paper Scissors
Rock Tie Loss Win
Paper Win Tie Loss
Scissors Loss Win Tie

Read a row from the player's perspective. Reading the matrix in the opposite direction swaps wins and losses while leaving ties unchanged. This is a useful diagnosis when a game's results are consistently reversed.

Guided practice

  1. Predict: calculate all nine outcomes by hand, then compare them with the matrix. Include the three ties rather than testing only winning combinations.
  2. Modify: accept R, P, and S as user-interface aliases. Convert aliases before calling the core function, and keep the core's accepted values unchanged.
  3. Reconstruct: write a score update function and verify that an invalid result leaves the score unchanged. State whether the function mutates its input or returns a new dictionary.

Keep game state separate from the history you display. If you add a round history later, derive a summary from that history and reconcile it with the counters. This catches bugs that a final “you won” message cannot expose.

Daily challenge

Create a playable command-line game and a test file. The user enters rock, paper, scissors, or quit. Invalid input displays a helpful message and continues without counting a round. Each valid round chooses one computer option, reports its outcome, and updates one counter. Quit reports total rounds and all three counts.

Requirement Acceptance evidence
Rules All nine pairs match the matrix
Validation Unknown choice raises ValueError in core
State Each valid round increases exactly one counter
Interaction Invalid input and quit do not count
Empty session Summary shows zero rounds

Test core logic before running the menu. Then manually enter a bad choice, one valid choice, and quit. Compare the displayed number of rounds with the number of accepted inputs. That tests the boundary the core tests do not cover.

Reference solution

Compare after attempting the challenge
# file: rps_app.py
import random

CHOICES = ("rock", "paper", "scissors")
BEATS = {"rock": "scissors", "paper": "rock", "scissors": "paper"}

def outcome(player, computer):
    if player not in CHOICES or computer not in CHOICES:
        raise ValueError("choose rock, paper, or scissors")
    if player == computer:
        return "tie"
    return "win" if BEATS[player] == computer else "loss"

def play_round(score, player, computer):
    result = outcome(player, computer)
    score[result] += 1
    return result

def run_game(input_fn=input, choose_fn=random.choice):
    score = {"win": 0, "loss": 0, "tie": 0}
    while True:
        player = input_fn("rock / paper / scissors / quit: ").strip().lower()
        if player == "quit":
            break
        if player not in CHOICES:
            print("Please choose rock, paper, scissors, or quit")
            continue
        computer = choose_fn(CHOICES)
        result = play_round(score, player, computer)
        print(f"You: {player}; computer: {computer}; result: {result}")
    print(f'Rounds: {sum(score.values())}; wins: {score["win"]}; '
          f'losses: {score["loss"]}; ties: {score["tie"]}')
    return score

if __name__ == "__main__":
    run_game()
# file: test_rps.py
from rps_app import outcome, play_round, run_game

expected = {
    ("rock", "rock"): "tie", ("rock", "paper"): "loss", ("rock", "scissors"): "win",
    ("paper", "rock"): "win", ("paper", "paper"): "tie", ("paper", "scissors"): "loss",
    ("scissors", "rock"): "loss", ("scissors", "paper"): "win", ("scissors", "scissors"): "tie",
}
for pair, result in expected.items():
    assert outcome(*pair) == result
score = {"win": 0, "loss": 0, "tie": 0}
try:
    play_round(score, "lizard", "rock")
except ValueError:
    pass
else:
    raise AssertionError("Unknown choice accepted")
assert score == {"win": 0, "loss": 0, "tie": 0}
answers = iter(["bad", " ROCK ", "quit"])
assert run_game(lambda prompt: next(answers), lambda choices: "scissors") == {
    "win": 1, "loss": 0, "tie": 0}
assert run_game(lambda prompt: "quit") == {"win": 0, "loss": 0, "tie": 0}
print("Nine rules, invalid input, and session accounting passed")

Run python test_rps.py to check the rules and a scripted interaction. Run python rps_app.py to play. The injected input and choice functions are ordinary parameters with defaults; they let the test control boundaries without replacing the real game logic.

Debug and explain

If outcomes reverse, check whose perspective the result names describe. If invalid inputs inflate rounds, move validation ahead of the update. If the test hangs, check that scripted input eventually includes quit. Explain why choosing the computer option outside outcome makes the core easier to test and why the total includes ties.

Review and next step

Save the nine-case matrix, the passing tests, and a short interaction transcript. You now have three projects with different contracts: persistent state, password generation, and deterministic rules with random gameplay. Day 30 asks you to combine the course's tools in a capstone with a defensible result.

Sources and verification

Original teaching examples checked with Python 3.12.14. Library behavior reviewed against these primary references on October 3, 2026. Day 26's examples also use Beautiful Soup 4.14.3.

DAILY CHECKPOINT

Check your understanding

Five questions, 20 points each. Pass at 85% or above, which requires all five correct. Feedback explains each answer; revise the lesson and retry when needed.

01. How many ordered choice pairs should the rule test cover?

02. From the player's perspective, rock against paper is what?

03. Should invalid input count as a played round?

04. Why keep computer selection outside outcome?

05. What should total rounds equal?

Review your practical work

Check these statements after running your own challenge. This is your self-review; the website does not execute or assess your Python code.

Pass the knowledge check and finish the practical self-review to save completion.