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
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)
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
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
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
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 inwinner_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!"
Concepts introduced:
- Top‑level orchestration lives in one file
-
Guarded input:
until ["1","2"].include?(mode)
andP/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
Run tests:
ruby -Itest test/test_game.rb
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
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
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
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
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
After (on the player object):
class Game
attr_accessor :score
def initialize; self.score = 0; end
def win; self.score += 1; end
end
4) Replay structure
Before (requires inside loop):
while true
require "./pvp.rb"
puts "Play again? q to quit"
break if gets.chomp == "q"
end
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
Run the game
ruby app.rb
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
)
Top comments (0)