Ruby Mastermind game project with AI

I'm self-learning and pretty new to coding and created a Mastermind game in Ruby. Any general feedback or advice whatsoever would be greatly appreciated. The game is completely functional right now, and has a simple AI. At the beginning the player chooses which role they want to choose (codebreaker or code maker).

Cheers

https://repl.it/repls/WeirdFrankButtons

class Game

def initialize
puts "---------------------------------"
puts "Welcome to Mastermind"
puts "The goal is to either create a 4 digit code (Code maker role) containing numbers ranging from 1 through 6 or to guess a code (Codebreaker role) created by the computer within 12 turns to win."
puts "After each guess you will be given an accuracy score indicating how close you were to guessing the code correctly."
puts "The letter \"H\" indicates one of the numbers you guessed is in the correct position. The letter \"h\" indicates you guessed a correct number but it is NOT in the correct position"
puts "----------------------------------"
@game_over = false
@turn = 1
until @comp_guess_mode === "Y" || @comp_guess_mode === "N"
print "Is the computer the code breaker? Y/N"
@comp_guess_mode = gets.chomp.upcase
end
game_mode
turn_sequence
end

def game_mode
if @comp_guess_mode == "Y"
human_code_generator
else
code_generator
end
end

def code_generator
@code = Array.new(4) {rand(1..6)}
end

def human_code_generator
@code = ""
puts "Please enter a 4 digit code"
until @code.length == 4
@code = gets.chomp.each_char.map(&:to_i)
end
end
# computer_guesser method that tests if the computer's guess matches the human's
# by iterating through the array, if a direct match ('H') is found it will keep that number in the next guess
def computer_guesser
@updated_comp_guess = [" "," "," "," "]
if @turn == 1
@guess = Array.new(4) {rand(1..6)}
else
i = 0
while i <4
if @guess[i] == @code[i]
@updated_comp_guess[i] = @guess[i]
i+=1
else
i +=1
end
end
end
@guess = Array.new(4) {rand(1..6)}
@updated_comp_guess.each_with_index do |value, idx|
if value != " "
@guess[idx] = value
end
end
puts "Guess: #{@guess.join}"
end

def codebreaker_guess
@guess = []
until @guess.length == 4
puts "Enter your 4 digit guess"
@guess = gets.chomp.each_char.map(&:to_i)
puts "Guess: #{@guess.join}"
if @guess.length != 4
print "Your guess was not 4 digits long, please guess again \n"
end
end
end

def turn_display
puts "-------------------------"
puts "It's turn number: #{@turn}"
end

#Repeats the following guess/check sequence for 12 turns
# or until the code and guess are matched
def turn_sequence
while @turn <13 && @game_over == false
turn_display
if @comp_guess_mode == "Y"
computer_guesser
else
codebreaker_guess
end
guess_checker
@turn += 1
victory_check
end
end

def guess_checker
@guess_accuracy = []
@i=0
@h_counter = 0
while @i<4
if @guess[@i] == @code[@i]
@guess_accuracy.push("H")
@h_counter += 1
@i+=1
else
@i+=1
end
end
if @i == 4
i = 0
compare_array = @code.clone
while i < 4
if compare_array.include?(@guess[i])
compare_array[(compare_array.index(@guess[i]))]= " "
@guess_accuracy.push("h")
i+=1
else
i+=1
end
end
@guess_accuracy.pop(@h_counter)
puts "Guess accuracy: #{@guess_accuracy.join}"
end
end

def victory_check
if @guess[0..3] == @code[0..3]
puts "Code was guessed correctly, it's #{@code}, codebreaker wins"
@game_over = true
elsif @turn == 13 && @game_over == false
puts "Code was not guessed correctly, code maker wins"
@game_over = true
end
end

end

game = Game.new

$$$$


Code 1

while i <4
if @guess[i] == @code[i]
@updated_comp_guess[i] = @guess[i]
i+=1
else
i +=1
end
end


In both the if and else you are incrementing i by 1. That can be made shorter.

while i <4
if @guess[i] == @code[i]
@updated_comp_guess[i] = @guess[i]
end
i += 1
end


Similar in the guess_checker further down, and in the compare_array

Consistency

Sometimes you are using 1 space for indentation, sometimes you are using 2. Sometimes you are using whitespace around operators, sometimes you don't, sometimes you are using whitespace on one side of the operator, but not the other. Sometimes, you use space after a comma, sometimes you don't. Sometimes you use one empty line after a method, sometimes two, sometimes none.

You should choose one style and stick with it. If you are editing some existing code, you should adapt your style to be the same as the existing code. If you are part of a team, you should adapt your style to match the rest of the team.

Most communities have developed standardized community style guides. In Ruby, there are multiple such style guides. They all agree on the basics (e.g. indentation is 2 spaces), but they might disagree on more specific points (single quotes or double quotes).

Indentation

The standard indentation style in Ruby is two spaces. You mostly use 2 spaces, but there is one place where you use 1 space. Stick with two.

Whitespace around operators

There should be 1 space either side of an operator. You sometimes use 1 space, sometimes no space, and sometimes space only on one side.

For example here, you have the exact same expression within three lines with two different spacing styles:

  i+=1
else
i +=1


They are inconsistent with each, and both of them don't comply with community guidelines. They should both be:

  i += 1


Space after comma

There should be 1 space after a comma. You sometimes use 1 space, sometimes no space.

E.g. here:

@updated_comp_guess = [" "," "," "," "]


should be

@updated_comp_guess = [" ", " ", " ", " "]


Space in blocks

In a block literal, there should be a space after the opening curly brace and one before the closing curly brace:

@code = Array.new(4) { rand(1..6) }


Single-quoted strings

If you don't use string interpolation, it is helpful if you use single quotes for your strings. That way, it is immediately obvious that no string interpolation is taking place.

In particular, this would also remove the escaping you need to do here:

puts 'The letter "H" indicates one of the numbers you guessed is in the correct position. The letter "h" indicates you guessed a correct number but it is NOT in the correct position'


Frozen string literals

Immutable data structures and purely functional code are always preferred, unless mutability and side-effects are required for clarity or performance. In Ruby, strings are always mutable, but there is a magic comment you can add to your files (also available as a command-line option for the Ruby engine), which will automatically make all literal strings immutable:

# frozen_string_literal: true


It is generally preferred to add this comment to all your files.

Conditional modifiers

When you have a conditional that executes only one expression, you should use the modifier form instead, e.g. this:

if value != " "
@guess[idx] = value
end


should be

@guess[idx] = value if value != " "


Same here:

until @code.length == 4
@code = gets.chomp.each_char.map(&:to_i)
end


should be

@code = gets.chomp.each_char.map(&:to_i) until @code.length == 4


Unnecessary parentheses

compare_array[(compare_array.index(@guess[i]))]= " "


The parentheses around compare_array.index(@guess[i]) are unnecessary.

Linting

You should run some sort of linter or static analyzer on your code. Rubocop is a popular one, but there are others.

Rubocop was able to detect all of the style violations I pointed out, and also was able to autocorrect all of them.

Let me repeat that: I have just spent two pages pointing out how to correct tons of stuff that you can actually correct within milliseconds at the push of a button. I have set up my editor such that it automatically runs Rubocop with auto-fix as soon as I hit "save".

In particular, running Rubocop on your code, it detects 98 offenses, of which it can automatically correct 76. This leaves you with 22 offenses, of which 11 are very simple.

Here's what the result of the auto-fix looks like:

# frozen_string_literal: true

class Game
def initialize
puts '---------------------------------'
puts 'Welcome to Mastermind'
puts 'The goal is to either create a 4 digit code (Code maker role) containing numbers ranging from 1 through 6 or to guess a code (Codebreaker role) created by the computer within 12 turns to win.'
puts 'After each guess you will be given an accuracy score indicating how close you were to guessing the code correctly.'
puts 'The letter "H" indicates one of the numbers you guessed is in the correct position. The letter "h" indicates you guessed a correct number but it is NOT in the correct position'
puts '----------------------------------'
@game_over = false
@turn = 1
until @comp_guess_mode === 'Y' || @comp_guess_mode === 'N'
print 'Is the computer the code breaker? Y/N'
@comp_guess_mode = gets.chomp.upcase
end
game_mode
turn_sequence
end

def game_mode
if @comp_guess_mode == 'Y'
human_code_generator
else
code_generator
end
end

def code_generator
@code = Array.new(4) { rand(1..6) }
end

def human_code_generator
@code = ''
puts 'Please enter a 4 digit code'
@code = gets.chomp.each_char.map(&:to_i) until @code.length == 4
end

# computer_guesser method that tests if the computer's guess matches the human's
# by iterating through the array, if a direct match ('H') is found it will keep that number in the next guess
def computer_guesser
@updated_comp_guess = [' ', ' ', ' ', ' ']
if @turn == 1
@guess = Array.new(4) { rand(1..6) }
else
i = 0
while i < 4
if @guess[i] == @code[i]
@updated_comp_guess[i] = @guess[i]
i += 1
else
i += 1
end
end
end
@guess = Array.new(4) { rand(1..6) }
@updated_comp_guess.each_with_index do |value, idx|
@guess[idx] = value if value != ' '
end
puts "Guess: #{@guess.join}"
end

def codebreaker_guess
@guess = []
until @guess.length == 4
puts 'Enter your 4 digit guess'
@guess = gets.chomp.each_char.map(&:to_i)
puts "Guess: #{@guess.join}"
print "Your guess was not 4 digits long, please guess again \n" if @guess.length != 4
end
end

def turn_display
puts '-------------------------'
puts "It's turn number: #{@turn}"
end

# Repeats the following guess/check sequence for 12 turns
# or until the code and guess are matched
def turn_sequence
while @turn < 13 && @game_over == false
turn_display
if @comp_guess_mode == 'Y'
computer_guesser
else
codebreaker_guess
end
guess_checker
@turn += 1
victory_check
end
end

def guess_checker
@guess_accuracy = []
@i = 0
@h_counter = 0
while @i < 4
if @guess[@i] == @code[@i]
@guess_accuracy.push('H')
@h_counter += 1
@i += 1
else
@i += 1
end
end
if @i == 4
i = 0
compare_array = @code.clone
while i < 4
if compare_array.include?(@guess[i])
compare_array[compare_array.index(@guess[i])] = ' '
@guess_accuracy.push('h')
i += 1
else
i += 1
end
end
@guess_accuracy.pop(@h_counter)
puts "Guess accuracy: #{@guess_accuracy.join}"
end
end

def victory_check
if @guess[0..3] == @code[0..3]
puts "Code was guessed correctly, it's #{@code}, codebreaker wins"
@game_over = true
elsif @turn == 13 && @game_over == false
puts 'Code was not guessed correctly, code maker wins'
@game_over = true
end
end
end

game = Game.new


And here are the offenses that Rubocop could not automatically correct:

Offenses:

game.rb:3:1: C: Metrics/ClassLength: Class has too many lines. [116/100]
class Game ...
^^^^^^^^^^
game.rb:3:1: C: Style/Documentation: Missing top-level class documentation comment.
class Game
^^^^^
game.rb:4:3: C: Metrics/MethodLength: Method has too many lines. [14/10]
def initialize ...
^^^^^^^^^^^^^^
game.rb:7:121: C: Layout/LineLength: Line is too long. [202/120]
puts 'The goal is to either create a 4 digit code (Code maker role) containing numbers ranging from 1 through 6 or to guess a code (Codebreaker role) created by the computer within 12 turns to win.'
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
game.rb:8:121: C: Layout/LineLength: Line is too long. [125/120]
puts 'After each guess you will be given an accuracy score indicating how close you were to guessing the code correctly.'
^^^^^
game.rb:9:121: C: Layout/LineLength: Line is too long. [186/120]
puts 'The letter "H" indicates one of the numbers you guessed is in the correct position. The letter "h" indicates you guessed a correct number but it is NOT in the correct position'
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
game.rb:13:28: C: Style/CaseEquality: Avoid the use of the case equality operator ===.
until @comp_guess_mode === 'Y' || @comp_guess_mode === 'N'
^^^
game.rb:13:56: C: Style/CaseEquality: Avoid the use of the case equality operator ===.
until @comp_guess_mode === 'Y' || @comp_guess_mode === 'N'
^^^
game.rb:41:3: C: Metrics/AbcSize: Assignment Branch Condition size for computer_guesser is too high. [<12, 12, 11> 20.22/17]
def computer_guesser ...
^^^^^^^^^^^^^^^^^^^^
game.rb:41:3: C: Metrics/MethodLength: Method has too many lines. [19/10]
def computer_guesser ...
^^^^^^^^^^^^^^^^^^^^
game.rb:50:11: C: Style/IdenticalConditionalBranches: Move i += 1 out of the conditional.
i += 1
^^^^^^
game.rb:52:11: C: Style/IdenticalConditionalBranches: Move i += 1 out of the conditional.
i += 1
^^^^^^
game.rb:80:3: C: Metrics/MethodLength: Method has too many lines. [11/10]
def turn_sequence ...
^^^^^^^^^^^^^^^^^
game.rb:94:3: C: Metrics/AbcSize: Assignment Branch Condition size for guess_checker is too high. [<16, 13, 11> 23.37/17]
def guess_checker ...
^^^^^^^^^^^^^^^^^
game.rb:94:3: C: Metrics/MethodLength: Method has too many lines. [27/10]
def guess_checker ...
^^^^^^^^^^^^^^^^^
game.rb:102:9: C: Style/IdenticalConditionalBranches: Move @i += 1 out of the conditional.
@i += 1
^^^^^^^
game.rb:104:9: C: Style/IdenticalConditionalBranches: Move @i += 1 out of the conditional.
@i += 1
^^^^^^^
game.rb:107:5: C: Style/GuardClause: Use a guard clause (return unless @i == 4) instead of wrapping the code inside a conditional expression.
if @i == 4
^^
game.rb:114:11: C: Style/IdenticalConditionalBranches: Move i += 1 out of the conditional.
i += 1
^^^^^^
game.rb:116:11: C: Style/IdenticalConditionalBranches: Move i += 1 out of the conditional.
i += 1
^^^^^^
game.rb:117:8: W: Layout/EndAlignment: end at 117, 7 is not aligned with if at 111, 8.
end
^^^
game.rb:135:1: W: Lint/UselessAssignment: Useless assignment to variable - game.
game = Game.new
^^^^

1 file inspected, 22 offenses detected


Let's look at the simple ones first.

Case equality operator

You use the case equality operator in a couple of places. Because of the way case equality is defined for strings, your code works purely "by accident". You should use the normal equality operator instead.

This:

until @comp_guess_mode === "Y" || @comp_guess_mode === "N"


should be this:

until @comp_guess_mode == "Y" || @comp_guess_mode == "N"


Note that you use the correct equality operator for the exact same check here:

if @comp_guess_mode == "Y"


Identical expressions in all branches

There are three places where you have the same expression in both branches of a conditional expression. That is unnecessary clutter, you can just pull the expression out of the conditional:

if @guess[i] == @code[i]
@updated_comp_guess[i] = @guess[i]
i+=1
else
i +=1
end


should be

if @guess[i] == @code[i]
@updated_comp_guess[i] = @guess[i]
end

i +=1


And as we said above, a conditional with only one expression should use the modifier form (note that this transformation would again be performed automatically by Rubocop if you ran it again):

@updated_comp_guess[i] = @guess[i] if @guess[i] == @code[i]

i +=1


Unused local variable

game = Game.new


game is never used anywhere. Just remove it:

Game.new


Guard clauses

If you have a case where an entire method or block is wrapped in a conditional, you can replace that with a "guard clause" and reduce the level of nesting.

E.g. this:

def something
if foo
bar
baz
quux
else
42
end
end


can become this:

def something
return 42 unless foo

bar
baz
quux
end


There are a couple of opportunities to do this in your code, and a couple more are created by following the Rubocop advice.

Here is one example where the improvement is not very big:

def game_mode
if @comp_guess_mode == "Y"
human_code_generator
else
code_generator
end
end

def game_mode
return human_code_generator if @comp_guess_mode == "Y"

code_generator
end


but here the gain is somewhat bigger:

def guess_checker
@guess_accuracy = []
@i=0
@h_counter = 0
while @i<4
if @guess[@i] == @code[@i]
@guess_accuracy.push("H")
@h_counter += 1
@i+=1
else
@i+=1
end
end
if @i == 4
i = 0
compare_array = @code.clone
while i < 4
if compare_array.include?(@guess[i])
compare_array[(compare_array.index(@guess[i]))]= " "
@guess_accuracy.push("h")
i+=1
else
i+=1
end
end
@guess_accuracy.pop(@h_counter)
puts "Guess accuracy: #{@guess_accuracy.join}"
end
end

def guess_checker
@guess_accuracy = []
@i = 0
@h_counter = 0

while @i < 4
if @guess[@i] == @code[@i]
@guess_accuracy.push('H')
@h_counter += 1
end
@i += 1
end

return unless @i == 4

i = 0
compare_array = @code.clone

while i < 4
if compare_array.include?(@guess[i])
compare_array[compare_array.index(@guess[i])]= ' '
@guess_accuracy.push('h')
end

i += 1
end

@guess_accuracy.pop(@h_counter)
puts "Guess accuracy: #{@guess_accuracy.join}"
end


Redundant checks

But actually, the entire thing is even simpler: since you loop and increment i 4 times, it will always be 4, so the condition will always be true and you can just remove it altogether.

Equality with booleans

@game_over == false


@game_over is already a boolean, there is no need to check for equality to false. This is just

!@game_over


Unnecessary instance variables

The instance variables @updated_comp_guess, @i, @h_counter, and @guess_accuracy are only ever used in one method. They should be local variables instead.

Loops

In Ruby, you almost never need loops. In fact, I would go so far and say that if you are using a loop in Ruby, you are doing it wrong.

Here's an example:

i = 0
while i < 4
updated_comp_guess[i] = @guess[i] if @guess[i] == @code[i]

i += 1
end


would be much better written as

4.times do |i|
updated_comp_guess[i] = @guess[i] if @guess[i] == @code[i]
end


This will make the guess_checker method look like this:

def guess_checker
guess_accuracy = []
h_counter = 0

4.times do |i|
if @guess[i] == @code[i]
guess_accuracy.push('H')
h_counter += 1
end
end

compare_array = @code.clone

4.times do |i|
if compare_array.include?(@guess[i])
compare_array[compare_array.index(@guess[i])] = ' '
guess_accuracy.push('h')
end
end

guess_accuracy.pop(h_counter)
puts "Guess accuracy: #{guess_accuracy.join}"
end


which gives us again the opportunity to use guard clauses:

def guess_checker
guess_accuracy = []
h_counter = 0

4.times do |i|
next unless @guess[i] == @code[i]

guess_accuracy.push('H')
h_counter += 1
end

compare_array = @code.clone

4.times do |i|
next unless compare_array.include?(@guess[i])

compare_array[compare_array.index(@guess[i])] = ' '
guess_accuracy.push('h')
end

guess_accuracy.pop(h_counter)
puts "Guess accuracy: #{guess_accuracy.join}"
end


Redundant expressions

In computer_guesser, if @turn == 1, you initialize @guess, and then you initialize it again without ever using it in between. The first initialization can just be removed, turning this:

if @turn == 1
@guess = Array.new(4) { rand(1..6) }
else
4.times do |i|
updated_comp_guess[i] = @guess[i] if @guess[i] == @code[i]
end
end

@guess = Array.new(4) { rand(1..6) }


into this:

unless @turn == 1
4.times do |i|
updated_comp_guess[i] = @guess[i] if @guess[i] == @code[i]
end
end

@guess = Array.new(4) { rand(1..6) }


Code duplication

Array.new(4) { rand(1..6) }


Appears multiple times in your code. It should be extracted into a method.

length vs. size

Many Ruby collections have both length and size methods, but some have only one. In general, IFF a collection has a size method, then that method is guaranteed to be "efficient" (usually constant time), whereas length may or may not be efficient (linear time for iterating through the collection and counting all the elements), depending on the collection.

In your case, you are using arrays and strings, for which both are constant time, but if you want to guarantee efficiency, then it is better to explicitly use size instead.

The Elephant in the room

One thing I have not addressed so far, and that I unfortunately do not have to time to address, is the fundamental design of the code. Everything I mentioned so far is just cosmetics.

All work is done in the initializer. All an initializer should do is initialize the object. It shouldn't ask for user input, it shouldn't print anything, it shouldn't play a game.

Also, you are mixing I/O and logic everywhere. A method should either print something or do something. Your design makes it impossible to test the code without actually playing the game. I cannot prepare a file with codes and guesses and feed it to a test runner, I actually have to manually play the game.

It is also strange that you have only one "object", namely the game, which is doing something. If you think about how the game is typically played, aren't the objects that are actively doing something the players and not the game? Where are the players in your design?

Unfortunately, I do not have time to dive into this.

Here is where the code currently stands:

# frozen_string_literal: true

class Game
def initialize
puts '---------------------------------'
puts 'Welcome to Mastermind'
puts 'The goal is to either create a 4 digit code (Code maker role) containing numbers ranging from 1 through 6 or to guess a code (Codebreaker role) created by the computer within 12 turns to win.'
puts 'After each guess you will be given an accuracy score indicating how close you were to guessing the code correctly.'
puts 'The letter "H" indicates one of the numbers you guessed is in the correct position. The letter "h" indicates you guessed a correct number but it is NOT in the correct position'
puts '----------------------------------'

@game_over = false
@turn = 1

until @comp_guess_mode == 'Y' || @comp_guess_mode == 'N'
print 'Is the computer the code breaker? Y/N'
@comp_guess_mode = gets.chomp.upcase
end

game_mode
turn_sequence
end

def game_mode
return human_code_generator if @comp_guess_mode == 'Y'

code_generator
end

def code_generator
@code = Array.new(4) { rand(1..6) }
end

def human_code_generator
@code = ''
puts 'Please enter a 4 digit code'
@code = gets.chomp.each_char.map(&:to_i) until @code.size == 4
end

# computer_guesser method that tests if the computer's guess matches the human's
# by iterating through the array, if a direct match ('H') is found it will keep that number in the next guess
def computer_guesser
updated_comp_guess = [' ', ' ', ' ', ' ']

unless @turn == 1
4.times do |i|
updated_comp_guess[i] = @guess[i] if @guess[i] == @code[i]
end
end

@guess = Array.new(4) { rand(1..6) }
updated_comp_guess.each_with_index do |value, idx|
@guess[idx] = value if value != ' '
end

puts "Guess: #{@guess.join}"
end

def codebreaker_guess
@guess = []

until @guess.size == 4
puts 'Enter your 4 digit guess'
@guess = gets.chomp.each_char.map(&:to_i)
puts "Guess: #{@guess.join}"
print "Your guess was not 4 digits long, please guess again \n" if @guess.size != 4
end
end

def turn_display
puts '-------------------------'
puts "It's turn number: #{@turn}"
end

# Repeats the following guess/check sequence for 12 turns
# or until the code and guess are matched
def turn_sequence
while @turn < 13 && !@game_over
turn_display
if @comp_guess_mode == 'Y'
computer_guesser
else
codebreaker_guess
end
guess_checker
@turn += 1
victory_check
end
end

def guess_checker
guess_accuracy = []
h_counter = 0

4.times do |i|
next unless @guess[i] == @code[i]

guess_accuracy.push('H')
h_counter += 1
end

compare_array = @code.clone

4.times do |i|
next unless compare_array.include?(@guess[i])

compare_array[compare_array.index(@guess[i])] = ' '
guess_accuracy.push('h')
end

guess_accuracy.pop(h_counter)
puts "Guess accuracy: #{guess_accuracy.join}"
end

def victory_check
if @guess == @code
puts "Code was guessed correctly, it's #{@code}, codebreaker wins"
@game_over = true
elsif @turn == 13 && !@game_over
puts 'Code was not guessed correctly, code maker wins'
@game_over = true
end
end
end

Game.new
$$$$