DEV Community

AB
AB

Posted on

From Functions to OOP: Building a Rock–Paper–Scissors CLI in Ruby

A beginner‑friendly, step‑by‑step guide that retraces a real learning journey—from a single file of functions to a clean, object‑oriented, tested command‑line app. Use it to rebuild the project from scratch and review key Ruby concepts any time.


What you’ll build

A terminal Rock–Paper–Scissors game with:

  • Two modes: Player vs Player, Player vs Computer
  • Object‑oriented design: a small Game class for player state and actions
  • Clean game logic: a single hash controls the win rules
  • Friendly CLI: ASCII art banner + robust input validation
  • Unit tests (MiniTest): pure logic tested without waiting for gets

Prereqs

  • Ruby (>= 3.x recommended)
  • Terminal basics (ruby file.rb)
ruby -v
Enter fullscreen mode Exit fullscreen mode

Final project structure

rock-paper-scissors/
├── app.rb            # Main CLI game loop (entry)
├── game_play.rb      # Winner logic + round runner (functions)
├── select.rb         # Game class (player: choice + score) and ASCII
└── test/
    └── test_game.rb  # MiniTest (optional)
Enter fullscreen mode Exit fullscreen mode

Note: the code below uses the same beginner‑friendly syntax as the repo (no keyword args, no heredocs, no advanced operators), so learners can cross‑check one‑to‑one.


Step 1 — Start the project

Create a folder and the files.

mkdir rock-paper-scissors && cd rock-paper-scissors
mkdir test
:> app.rb :> game_play.rb :> select.rb :> test/test_game.rb
Enter fullscreen mode Exit fullscreen mode

Step 2 — The Player class (select.rb)

This class will:

  • hold a player’s score and latest choice
  • prompt and validate input (shoot)
  • generate a random choice for the computer (shoot_computer)

Add ASCII constants, then the class.

# select.rb
# ASCII art (simple multiline strings)
THE_ROCK = "
    _______
---'   ____)
      (_____)
      (_____)
      (____)
---.__(___)
"

THE_PAPER = "
    _______
---'   ____)____
          ______)
          _______)
         _______)
---.__________)
"

THE_SCISSORS = "
    _______
---'   ____)____
          ______)
       __________)
      (____)
---.__(___)
"

# Map of symbol -> ASCII art
OPTIONS = { :rock => THE_ROCK, :paper => THE_PAPER, :scissors => THE_SCISSORS }

# Player object (choice + score) and input methods
class Game
  attr_accessor :choice, :score

  def initialize
    self.choice = ""
    self.score  = 0
  end

  def shoot
    options = OPTIONS.keys.map { |k| k.to_s }
    @choice = ""
    while !options.include?(@choice)
      puts "Please choose from: Rock, Paper, or Scissors"
      @choice = gets.chomp.downcase
    end
    @choice
  end

  def shoot_computer
    options  = OPTIONS.keys.map { |k| k.to_s }
    @choice  = options.sample
    puts "Computer chose: #{@choice.capitalize}"
    @choice
  end

  def win
    @score += 1
  end
end
Enter fullscreen mode Exit fullscreen mode

Concepts introduced:

  • Constants (THE_ROCK, OPTIONS)
  • Symbols vs strings: :rock keys but we compare with lowercase strings from input (.to_s)
  • Encapsulation: player state (@score, @choice) lives inside the object
  • Loops & I/O: a while + gets.chomp for robust input

Step 3 — Core game logic (game_play.rb)

We’ll keep winner logic data‑driven with a single hash. If POSSIBILITIES[p1] == p2, then player 1 wins.

# game_play.rb
require "./select.rb"

# Win rules (strings to match input)
POSSIBILITIES = {
  "rock"     => "scissors",
  "scissors" => "paper",
  "paper"    => "rock"
}

VS = "
 _   _  ___  
( ) ( )(  _`\\
| | | || (_(_)
| | | |`\\__ \\
| \\_/ |( )_) |
`\\___/'`\\____)
"

# Pure helper that returns :tie, :p1, or :p2
def winner_result(choice1, choice2)
  return :tie if choice1 == choice2
  return :p1  if POSSIBILITIES[choice1] == choice2
  :p2
end

# One round. If vs_computer is true, second player is the CPU.
# Prints hands, result, and updates scores.
# Returns :tie, :p1, or :p2

def play_round(player_1, player_2, vs_computer)
  puts "Player 1:"
  player_1_choice = player_1.shoot

  if vs_computer
    puts "Computer:"
    player_2_choice = player_2.shoot_computer
  else
    puts "Player 2:"
    player_2_choice = player_2.shoot
  end

  result = winner_result(player_1_choice, player_2_choice)

  if result == :p1
    player_1.win
  elsif result == :p2
    player_2.win
  end

  puts "\nPlayer 1:\n#{OPTIONS[player_1_choice.to_sym]}"
  puts VS
  puts "Player 2:\n#{OPTIONS[player_2_choice.to_sym]}"

  if result == :tie
    puts "\nTie!"
  elsif result == :p1
    puts "\nPlayer 1 wins"
  else
    puts vs_computer ? "\nComputer wins" : "\nPlayer 2 wins"
  end

  puts "\nPlayer 1 Score: #{player_1.score}, Player 2 Score: #{player_2.score}\n"

  result
end
Enter fullscreen mode Exit fullscreen mode

Concepts introduced:

  • Hash as rule table (smallest DSA that solves the problem cleanly)
  • Pure function winner_result (great for tests)
  • Separation of concerns: printing/UI is in play_round; rule is in winner_result

Step 4 — The main game loop (app.rb)

A banner, choose a mode, and a replay loop with input validation.

# app.rb
require "./select.rb"
require "./game_play.rb"

puts "WELCOME To The Game of
    _______             _______             _______
---'   ____)       ---'    ____)____   ---'    ____)____
      (_____)             ______)             ______)
      (_____)             _______)          __________)
      (____)              _______)          (____)
---.__(___)         ---.__________)         ---.__(___)
Rock                  Paper                   Scissors"

# Choose mode
mode = ""
until ["1","2"].include?(mode)
  puts "Choose a mode: 1) Player vs Player  2) Player vs Computer"
  mode = gets.chomp
end

vs_computer = (mode == "2")

player_1 = Game.new
player_2 = Game.new

# Replay loop
loop do
  play_round(player_1, player_2, vs_computer)

  puts "Play again? (P to play, Q to quit)"
  replay = gets.chomp.downcase
  break if replay == "q"
end

puts "Thanks for playing!"
Enter fullscreen mode Exit fullscreen mode

Concepts introduced:

  • Top‑level orchestration lives in one file
  • Guarded input: until ["1","2"].include?(mode) and P/Q replay prompt
  • Keep require at the top (don’t reload code each round)

Step 5 — Unit tests with MiniTest (test/test_game.rb)

We test the pure logic and the computer chooser—no hanging tests waiting for gets.

# test/test_game.rb
require "minitest/autorun"
require_relative "../select"
require_relative "../game_play"

class RpsTest < Minitest::Test
  def test_winner_result_all_pairs
    assert_equal :tie, winner_result("rock", "rock")
    assert_equal :p1,  winner_result("rock", "scissors")
    assert_equal :p2,  winner_result("rock", "paper")

    assert_equal :p1,  winner_result("paper", "rock")
    assert_equal :p2,  winner_result("paper", "scissors")

    assert_equal :p1,  winner_result("scissors", "paper")
    assert_equal :p2,  winner_result("scissors", "rock")
  end

  def test_computer_choice_is_valid
    cpu = Game.new
    50.times do
      choice = cpu.shoot_computer
      assert_includes ["rock","paper","scissors"], choice
    end
  end

  def test_scoring_when_p1_wins
    p1 = Game.new
    p2 = Game.new
    result = winner_result("rock","scissors")
    p1.win if result == :p1

    assert_equal 1, p1.score
    assert_equal 0, p2.score
  end
end
Enter fullscreen mode Exit fullscreen mode

Run tests:

ruby -Itest test/test_game.rb
Enter fullscreen mode Exit fullscreen mode

Concepts introduced:

  • Testing I/O‑heavy code by isolating logic into pure helpers
  • Assertions: assert_equal, assert_includes

Refactor Showcases — from procedural X ➜ clean OOP Y

All examples use the same beginner syntax as the repo (no keyword args, no heredocs, no &.). Use these as quick diffs when you’re cleaning up code.

1) Input validation loop

Before (leaks invalid values):

OPTIONS = ["rock","paper","scissors"]

def shoot
  puts "Choose Rock, Paper, or Scissors:"
  choice = gets.chomp.downcase
  unless OPTIONS.include?(choice)
    puts "Invalid choice"
  end
  choice # may be invalid
end
Enter fullscreen mode Exit fullscreen mode

After (clean loop; returns only valid):

OPTIONS = { :rock=>nil, :paper=>nil, :scissors=>nil }

def shoot
  options = OPTIONS.keys.map { |k| k.to_s }
  choice = ""
  while !options.include?(choice)
    puts "Choose Rock, Paper, or Scissors:"
    choice = gets.chomp.downcase
  end
  choice
end
Enter fullscreen mode Exit fullscreen mode

2) Winner logic

Before (branch soup):

def check_winner(c1, c2)
  if c1=="rock" && c2=="scissors"
    :p1
  elsif c1=="scissors" && c2=="paper"
    :p1
  elsif c1=="paper" && c2=="rock"
    :p1
  elsif c1==c2
    :tie
  else
    :p2
  end
end
Enter fullscreen mode Exit fullscreen mode

After (tiny rule table):

POSSIBILITIES = { "rock"=>"scissors", "scissors"=>"paper", "paper"=>"rock" }

def winner_result(c1, c2)
  return :tie if c1 == c2
  return :p1  if POSSIBILITIES[c1] == c2
  :p2
end
Enter fullscreen mode Exit fullscreen mode

3) Score handling

Before (globals):

$p1_score = 0
$p2_score = 0

def win!(who)
  if who == :p1 then $p1_score += 1 else $p2_score += 1 end
end
Enter fullscreen mode Exit fullscreen mode

After (on the player object):

class Game
  attr_accessor :score
  def initialize; self.score = 0; end
  def win; self.score += 1; end
end
Enter fullscreen mode Exit fullscreen mode

4) Replay structure

Before (requires inside loop):

while true
  require "./pvp.rb"
  puts "Play again? q to quit"
  break if gets.chomp == "q"
end
Enter fullscreen mode Exit fullscreen mode

After (single entry file + clean call):

require "./select.rb"
require "./game_play.rb"

loop do
  play_round(player_1, player_2, vs_computer)
  puts "Play again? (q to quit)"
  break if gets.chomp.downcale == "q"
end
Enter fullscreen mode Exit fullscreen mode

Run the game

ruby app.rb
Enter fullscreen mode Exit fullscreen mode

Follow the prompts:

  • 1 = Player vs Player
  • 2 = Player vs Computer
  • After each round: P to play again, Q to quit

What you learned (Ruby & design)

  • OOP essentials: classes, instance variables, encapsulation, methods
  • CLI I/O: gets.chomp, printing, ASCII fun
  • Control flow: loops, input validation
  • Data structures: arrays & a hash as a rule table (DRY and scalable)
  • Separation of concerns: tiny files with single responsibilities
  • Testing mindset: factor logic into pure functions and test them

Stretch ideas

  • “Best of N” series
  • Score persistence across runs (save to a file)
  • Add Lizard/Spock—just extend the POSSIBILITIES hash
  • Colorized output (gems like colorize)

rock-paper-scissors

Top comments (0)