Build Rock-Paper-Scissors Through Small Iterations
Ruby source files are converted directly from the English source tree.
en/topic_10_rock_paper_scissors
Overview
Why this topic matters ๐ก
This topic teaches development as a sequence of small, testable iterations rather than a single "build the whole game" task. Rock-paper-scissors is intentionally simple as a domain, which makes it a good vehicle for discussing rules, round resolution, score tracking, and dependency injection.
Learning outcomes ๐ฏ
By the end of this topic, students should be able to:
- decompose a small game into clear objects with narrow responsibilities;
- build behavior in short sprints instead of one large implementation jump;
- separate domain rules from interaction flow;
- inject a move source to make game behavior deterministic in tests;
- explain how iterative TDD changes design quality.
Assessment focus โ
Students should be able to explain why the game is split into rules, round, and match concerns rather than collapsed into one script.
Short Note
A tiny game is useful because the rules are easy to understand, so students can focus on design decisions:
- what object knows the rules?
- what object resolves a single round?
- what object tracks progress across rounds?
This topic is not about making a flashy game. It is about practicing disciplined iteration. Students should feel the benefit of building one slice at a time.
Ruby beauty in this topic:
- small domain objects stay readable;
- hashes and symbols make round summaries easy to inspect;
- injected collaborators make even a toy game easy to test.
Ruby caution in this topic:
- toy projects become procedural quickly if all behavior goes into one class;
- randomness should be isolated or tests become brittle.
Reflection prompt:
- Which part of the design became easier because the computer move source was injected?
Worked Examples
Example 1: Rule engine before game flow ๐ก
It is tempting to write "if/else" game logic inside the main game object. A better teaching move is to separate the rule engine first:
rules.winner("rock", "scissors")
# => :player_oneruby
Why this is useful:
- the domain rule can be tested independently;
- later objects can depend on a stable rule contract;
- the game flow code becomes smaller.
Example 2: Deterministic testing of a game loop ๐ก
Games often involve randomness, but tests should not.
If the computer move comes from an injected collaborator, the test can control the sequence:
source = instance_double("MoveSource")
allow(source).to receive(:next_move).and_return("scissors", "rock")ruby
This is the most important design lesson in the topic: isolate unstable behavior.
Cheatsheet
Rule lookup
WINNING_MOVES = {
"rock" => "scissors",
"paper" => "rock",
"scissors" => "paper"
}.freezeruby
Round summary
{
player_move: "rock",
computer_move: "scissors",
outcome: :win,
message: "rock beats scissors"
}ruby
Injected collaborator
def initialize(round:, computer_move_source:, target_wins: 2)
@round = round
@computer_move_source = computer_move_source
@target_wins = target_wins
endruby
Sprint mindset ๐
- Sprint 1: rule resolution
- Sprint 2: single round summary
- Sprint 3: multi-round score tracking
- Sprint 4: optional CLI or UI ideas
Study Guide
Topic purpose ๐ฏ
Use a familiar game to practice iterative design. The aim is not to finish a big project quickly; it is to feel how a small domain becomes easier when broken into sprints with explicit responsibilities.
Study sequence ๐ช
- Read
overview.md. - Read
shortnote.md. - Study
worked_examples.md. - Keep
cheatsheet.mdopen while readingexample.rb. - Complete the basic exercise.
- Complete the advanced exercise.
Sprint plan ๐
Sprint 1: Rules
- Understand valid moves.
- Decide who wins from two moves.
- Keep this logic independent from the rest of the game.
Sprint 2: Round resolution
- Build one object that resolves a single round.
- Return a summary that another part of the system can display or store.
Sprint 3: Match flow
- Track score across rounds.
- Inject the computer move source so tests stay deterministic.
- Stop thinking "random game" and start thinking "testable state transitions."
Sprint 4: Optional extension ideas
- best-of-five mode;
- command-line input loop;
- alternate move sources;
- richer round history for reporting.
Reflection questions ๐ค
- Why is a
MoveRulesobject better than scattering comparisons through the game? - What changed in the design once score tracking appeared?
- Why is dependency injection useful even for a toy game?
Source Files and Tests
Ruby source files are converted directly from the English source tree.
# EXAMPLE CODE
# Topic: topic_10_rock_paper_scissors
#
# Purpose:
# - This file demonstrates a reference implementation for the rule engine.
# - It should pass tests from the beginning.
# - Read it before solving the round and match exercises.
class MoveRules
WINNING_MOVES = {
"rock" => "scissors",
"paper" => "rock",
"scissors" => "paper"
}.freeze
def valid_move?(move)
WINNING_MOVES.key?(move)
end
def winner(player_one_move, player_two_move)
validate_move!(player_one_move)
validate_move!(player_two_move)
return :draw if player_one_move == player_two_move
WINNING_MOVES[player_one_move] == player_two_move ? :player_one : :player_two
end
private
def validate_move!(move)
raise ArgumentError, "invalid move: #{move}" unless valid_move?(move)
end
end
Ruby course source
# STUDENT TASK (BASIC)
# Topic: topic_10_rock_paper_scissors
#
# What to do:
# - Build a single-round object on top of `MoveRules`.
# - Return a round summary instead of printing directly to the screen.
# - Use the tests as the contract for the object shape.
#
# Expected outcome:
# - You can resolve one round and report the result clearly.
class Round
def initialize(rules: MoveRules.new)
@rules = rules
end
def play(player_move, computer_move)
winner = @rules.winner(player_move, computer_move)
{
player_move: player_move,
computer_move: computer_move,
outcome: outcome_for(winner),
message: message_for(player_move, computer_move, winner)
}
end
private
def outcome_for(winner)
case winner
when :player_one then :win
when :player_two then :lose
else :draw
end
end
def message_for(player_move, computer_move, winner)
return "draw" if winner == :draw
winning_move = winner == :player_one ? player_move : computer_move
losing_move = winner == :player_one ? computer_move : player_move
"#{winning_move} beats #{losing_move}"
end
end
Ruby course source
# STUDENT TASK (ADVANCED)
# Topic: topic_10_rock_paper_scissors
#
# Academic purpose:
# - Practice iterative design by growing from one round into a small match engine.
# - Show how dependency injection makes a game loop predictable in tests.
#
# Real-world use case:
# - Any stateful workflow with an unstable external input source benefits from this pattern.
# - In a game, the unstable input is randomness.
# - In business code, the unstable input might be time, network data, or an external service.
#
# Why Ruby is beautiful here:
# - The match engine can return simple hashes that are easy to inspect.
# - The injected move source can be any object with `next_move`.
# - The state transitions stay clear without a large framework.
#
# What to do:
# - Track score across turns.
# - Ask the computer move source for the next move each turn.
# - Return enough information for a caller to display the game state.
#
# Expected outcome:
# - Advanced tests pass and you can explain how the object was built in small sprints.
class MatchEngine
def initialize(round:, computer_move_source:, target_wins: 2)
@round = round
@computer_move_source = computer_move_source
@target_wins = target_wins
@score = { player: 0, computer: 0 }
end
def play_turn(player_move)
computer_move = @computer_move_source.next_move
round_summary = @round.play(player_move, computer_move)
update_score!(round_summary[:outcome])
round_summary.merge(
score: @score.dup,
match_winner: match_winner
)
end
private
def update_score!(outcome)
case outcome
when :win then @score[:player] += 1
when :lose then @score[:computer] += 1
end
end
def match_winner
return :player if @score[:player] >= @target_wins
return :computer if @score[:computer] >= @target_wins
nil
end
end
Ruby course source
# ANSWER KEY (BASIC)
# Topic: topic_10_rock_paper_scissors
#
# Solution idea:
# - Build one object that resolves a single round.
# - Delegate winner detection to `MoveRules`.
# - Return a structured summary so the caller can display or store the result later.
class Round
def initialize(rules: MoveRules.new)
@rules = rules
end
def play(player_move, computer_move)
winner = @rules.winner(player_move, computer_move)
{
player_move: player_move,
computer_move: computer_move,
outcome: outcome_for(winner),
message: message_for(player_move, computer_move, winner)
}
end
private
def outcome_for(winner)
case winner
when :player_one then :win
when :player_two then :lose
else :draw
end
end
def message_for(player_move, computer_move, winner)
return "draw" if winner == :draw
winning_move = winner == :player_one ? player_move : computer_move
losing_move = winner == :player_one ? computer_move : player_move
"#{winning_move} beats #{losing_move}"
end
end
Ruby course source
# ANSWER KEY (ADVANCED)
# Topic: topic_10_rock_paper_scissors
#
# Solution idea:
# - Keep one `Round` object responsible for single-turn logic.
# - Let `MatchEngine` handle only repeated turns and score tracking.
# - Inject the move source so tests can control the computer's sequence.
class MatchEngine
def initialize(round:, computer_move_source:, target_wins: 2)
@round = round
@computer_move_source = computer_move_source
@target_wins = target_wins
@score = { player: 0, computer: 0 }
end
def play_turn(player_move)
computer_move = @computer_move_source.next_move
round_summary = @round.play(player_move, computer_move)
update_score!(round_summary[:outcome])
round_summary.merge(
score: @score.dup,
match_winner: match_winner
)
end
private
def update_score!(outcome)
case outcome
when :win then @score[:player] += 1
when :lose then @score[:computer] += 1
end
end
def match_winner
return :player if @score[:player] >= @target_wins
return :computer if @score[:computer] >= @target_wins
nil
end
end
Ruby course source
# This spec is your learning companion for topic_10_rock_paper_scissors.
#
# How to use this file:
# 1) Run tests and observe failures or successes.
# 2) Keep the EXAMPLE specs green from the beginning.
# 3) Implement the BASIC exercise as sprint 2: one round.
# 4) Implement the ADVANCED exercise as sprint 3: the match engine.
#
# Expected final result:
# - All examples in this file pass.
# - You understand how to grow a small game through short iterations.
require_relative "../example"
require_relative "../basic_exercise"
require_relative "../adv_exercise"
RSpec.describe "topic_10_rock_paper_scissors" do
describe "EXAMPLE purpose: understand the rule engine before building the game" do
it "determines the winner for valid moves" do
rules = MoveRules.new
expect(rules.valid_move?("rock")).to eq(true)
expect(rules.winner("rock", "scissors")).to eq(:player_one)
expect(rules.winner("paper", "paper")).to eq(:draw)
expect { rules.winner("lizard", "rock") }.to raise_error(ArgumentError, /invalid move/)
end
end
describe "BASIC EXERCISE purpose: build one round as a small sprint" do
it "returns a round summary without printing directly" do
round = Round.new
expect(round.play("rock", "scissors")).to eq(
{
player_move: "rock",
computer_move: "scissors",
outcome: :win,
message: "rock beats scissors"
}
)
expect(round.play("paper", "paper")[:outcome]).to eq(:draw)
end
end
describe "ADVANCED EXERCISE purpose: track a match through iterative turns" do
it "uses an injected move source to keep the match deterministic" do
source = instance_double("MoveSource")
allow(source).to receive(:next_move).and_return("scissors", "rock")
engine = MatchEngine.new(
round: Round.new,
computer_move_source: source,
target_wins: 2
)
first_turn = engine.play_turn("rock")
second_turn = engine.play_turn("paper")
expect(first_turn[:score]).to eq({ player: 1, computer: 0 })
expect(first_turn[:match_winner]).to eq(nil)
expect(second_turn[:score]).to eq({ player: 2, computer: 0 })
expect(second_turn[:match_winner]).to eq(:player)
end
end
end
Ruby course source
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
exec "${ROOT_DIR}/run_tests.sh" "$(basename "${SCRIPT_DIR}")"
Ruby course source
Study Prompts
Read the spec first and identify which behaviors belong to the example, basic exercise, and advanced exercise.
Attempt the exercises before opening the answer files, then compare the design choices.