10

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_one
worked_examples.md
ruby

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")
worked_examples.md
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"
}.freeze
cheatsheet.md
ruby

Round summary

{
  player_move: "rock",
  computer_move: "scissors",
  outcome: :win,
  message: "rock beats scissors"
}
cheatsheet.md
ruby

Injected collaborator

def initialize(round:, computer_move_source:, target_wins: 2)
  @round = round
  @computer_move_source = computer_move_source
  @target_wins = target_wins
end
cheatsheet.md
ruby

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 ๐Ÿชœ

  1. Read overview.md.
  2. Read shortnote.md.
  3. Study worked_examples.md.
  4. Keep cheatsheet.md open while reading example.rb.
  5. Complete the basic exercise.
  6. 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 MoveRules object 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
en/topic_10_rock_paper_scissors/example.rb
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
en/topic_10_rock_paper_scissors/basic_exercise.rb
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
en/topic_10_rock_paper_scissors/adv_exercise.rb
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
en/topic_10_rock_paper_scissors/answer_basic_exercise.rb
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
en/topic_10_rock_paper_scissors/answer_adv_exercise.rb
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
en/topic_10_rock_paper_scissors/tests/topic_10_rock_paper_scissors_spec.rb
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}")"
en/topic_10_rock_paper_scissors/run_topic_tests.sh
Ruby course source

Study Prompts

  1. Read the spec first and identify which behaviors belong to the example, basic exercise, and advanced exercise.

  2. Attempt the exercises before opening the answer files, then compare the design choices.