Mastermind

This article is the first part of a TDD (test driven development) example in Ruby. It implements a game named Mastermind.

We are going to split the tests in two parts: the tests for the rules and the tests for the game. We show all the tests first so that the reader can implement them before reading the solution presented here.

We are going to implement the tests in the order in which they appear, leading to refactorings and fixes, when the following tests fail.

The second part ends with an implementation of a simple text user interface (text UI).

The game

The player must find a key code, he/she makes guesses and receives a feedback. He/she has a limit of 12 guesses otherwise he/she loses. The key code contains 4 colours out of 8 possible colours. As feedback, the user receives white or black pegs. Each white peg means that the guess has one correct colour but is not at the right place. Each black peg means the guess has one correct colour at the right place. The player does not know which colour is the correct one.

As an example, let the key code be RBGW (red, blue, green and white). If the player guesses RWOY (red, white, orange and yellow), he/she receives one white and one black peg.

The tests for the rules

Test names contain a number, so that we can use patterns to run part of the tests (regular expression pattern with the -n option).

If we wanted to run the first three tests we would write:

ruby game_engine_test.rb -n /test_0[123]/

We made a decision about the implementation, we use something we call a GameEngine which knows everything about the rules but does not interact with the user, it is an API for the UI code.

game_engine_test.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
require 'test/unit'

require_relative 'game_engine'

class GameEngineTests < Test::Unit::TestCase

  def setup
    @engine = GameEngine.new('GRWR')
  end
  
  def test_01_totally_wrong_pattern
    assert_equal [], @engine.check('BOYB')
  end
  
  def test_02_correct_key_code
    assert_equal [:black, :black, :black, :black], @engine.check('GRWR')
  end
  
  def test_03_one_correct_color
    assert_equal [:white], @engine.check('ROYB')
  end
  
  def test_04_two_correct_colors
    assert_equal [:white, :white], @engine.check('ROYG')
  end
  
  def test_05_three_correct_colors    
    assert_equal [:white, :white, :white], @engine.check('RWYG')
  end
  
  def test_06_one_correct_color_and_right_position
    assert_equal [:black], @engine.check('GOYB')
  end
  
  def test_07_three_correct_colors_with_one_at_right_position
    assert_equal [:white, :white, :black], @engine.check('RYWG')
  end
  
  def test_08_engine_is_not_case_sensitive
    assert_equal [:white, :white, :black], @engine.check('rywg')
  end
  
  def test_09_only_4_character_codes_are_valid
    assert_equal [], @engine.check('rywgYY')
  end
  
  def test_10_repeating_same_color_produces_correct_feedback
    assert_equal [:black], @engine.check('GGGG')
  end
  
end

class GameEngineIsNotCaseSensitiveTest < Test::Unit::TestCase
  
  def test_engine_is_not_case_sensitive
    
    @engine = GameEngine.new('GRWR')
    
    assert_equal [:white, :white, :black], @engine.check('rywg')
    
  end
  
end

In the tests above we see that we need a GameEngine class which needs the (hidden) key code to create new instances, as shown hereafter.

game_engine.rb
1
2
3
4
5
6
7
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
end

The APIs we imagined in the tests expect the class to provide a check method. It checks if the given pattern, a guess, is correct.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern
  end

end

Let’s start making the tests pass…

Test: guessing a totally wrong pattern

When a guess is totally wrong we receive an empty array as a result. All we need to do is return an empty array.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern
    []
  end

end

Run the test and see it passes. (we use the -n option of unit test command line to limit the test run to the first test)

$> ruby  game_engine_test.rb -n test_01_totally_wrong_pattern
Loaded suite /tmp/release/mastermind/game_engine_test
Started
.

Finished in 0.000362792 seconds.
------
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
2756.40 tests/s, 2756.40 assertions/s

Our first success is of course useless, it reflects the usual: implement just enough to make the test pass…

Test: correct key code

The easier is to test if the pattern is equal to the hidden key code.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern
    if pattern == @key_code
      [:black, :black, :black, :black]
    end
  end

end

We must add the else branch, which returns an empty array so that the first test still passes.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern
    if pattern == @key_code
      [:black, :black, :black, :black]
    else
      []
    end
  end

end

Run the two tests.

$> ruby  game_engine_test.rb -n /test_0[12]/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
..

Finished in 0.000442805 seconds.
------
2 tests, 2 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
4516.66 tests/s, 4516.66 assertions/s

Test: one correct color

The first idea that came to mind was to play with strings, as the key code is a String object.

The String class has a method named tr, translate, which takes two String parameters. The first gives the list of characters we want to translate and the second gives the replacement characters. So, we could replace all the characters from the key code that appear in the guessed pattern with nothing and keep the invalid colours. If we know how many invalid colours the pattern does contain, we can compute how many valid colours it contains.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern
    if pattern == @key_code
      [:black, :black, :black, :black]
    else
      translated = pattern.tr(@key_code, '')
      pegs = [:white] * (pattern.length - translated.length)
    end
  end

end

As the current test only checks the behaviour of the engine for a valid colour, not for a correct position, the above implementation is the simplest that makes the test pass.

Line 13, generates an array containing :white symbols of the computed size: the length of the given pattern (guessed colours) minus the length of the invalid colours.

$> ruby  game_engine_test.rb -n test_03_one_correct_color
Loaded suite /tmp/release/mastermind/game_engine_test
Started
.

Finished in 0.000374623 seconds.
------
1 tests, 1 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
2669.35 tests/s, 2669.35 assertions/s

The test passes, but the name translated we used on line 13 is not clear, a better name would be invalid_colors. Let’s refactor the code.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern
    if pattern == @key_code
      [:black, :black, :black, :black]
    else
      invalid_colors = pattern.tr(@key_code, '')
      pegs = [:white] * (pattern.length - invalid_colors.length)
    end
  end

end

Run the first 3 tests to check we didn’t break them.

$> ruby  game_engine_test.rb -n /test_0[1-3]/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
...

Finished in 0.000546604 seconds.
------
3 tests, 3 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
5488.43 tests/s, 5488.43 assertions/s

Tests: two or three correct colors

We don’t need to change the current code for the two tests…

$> ruby  game_engine_test.rb -n /test_0[1-5]/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
.....

Finished in 0.000694142 seconds.
------
5 tests, 5 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
7203.14 tests/s, 7203.14 assertions/s

Test: one correct color and right position

The code returns only :white symbols even if the colours are at a correct position. We must check for correct positions, remove one :white symbol for each correct position and add one :black symbol.

As we only have four colours we can simply use comparisons.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern
    if pattern == @key_code
      [:black, :black, :black, :black]
    else
      invalid_colors = pattern.tr(@key_code, '')
      pegs = [:white] * (pattern.length - invalid_colors.length)

      if @key_code[0] == pattern[0]
        pegs.shift
        pegs << :black
      end

      if @key_code[1] == pattern[1]
        pegs.shift
        pegs << :black
      end

      if @key_code[2] == pattern[2]
        pegs.shift
        pegs << :black
      end

      if @key_code[3] == pattern[3]
        pegs.shift
        pegs << :black
      end

      pegs      

    end
  end

end

We repeat four times the same code pattern: compare the first character of the key code with the first character of the given pattern (line 15), if they match we remove one white peg (line 16) and add a black peg (line 17). We repeat the same for the next three characters (colours).

$> ruby  game_engine_test.rb -n /test_0[1-6]/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
......

Finished in 0.000802015 seconds.
------
6 tests, 6 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
7481.16 tests/s, 7481.16 assertions/s

Test: three correct colors with one at right position

We are at the 7th test which should pass…

$> ruby  game_engine_test.rb -n /test_0[1-7]/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
.......

Finished in 0.001051272 seconds.
------
7 tests, 7 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
6658.60 tests/s, 6658.60 assertions/s

Test: engine is not case sensitive

What does engine is not case sensitive mean?

As we use strings to represent colour sequences, we would like to have upper case letters to be equal to lower case letters. For instance, we would like R, meaning red, to be equal to r.

An easy way to resolve this case is to work with upper case letters. We can first force conversion of the given pattern to upper case letters.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern

    pattern = pattern.upcase

    if pattern == @key_code
      [:black, :black, :black, :black]
    else
      invalid_colors = pattern.tr(@key_code, '')
      pegs = [:white] * (pattern.length - invalid_colors.length)

      if @key_code[0] == pattern[0]
        pegs.shift
        pegs << :black
      end

      if @key_code[1] == pattern[1]
        pegs.shift
        pegs << :black
      end

      if @key_code[2] == pattern[2]
        pegs.shift
        pegs << :black
      end

      if @key_code[3] == pattern[3]
        pegs.shift
        pegs << :black
      end

      pegs      

    end
  end

end

That’s all we need to do.

$> ruby  game_engine_test.rb -n /test_0[1-8]/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
........

Finished in 0.000962493 seconds.
------
8 tests, 8 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
8311.75 tests/s, 8311.75 assertions/s

The code expects that the key code contains upper letters but we do not ensure it in the initialize method (object creation). Later, we are going to improve the code.

Test: only 4-character codes are valid

If the given pattern does not contain four colours the engine returns an empty array, we consider it as totally invalid.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern

    return [] unless pattern.length == 4

    pattern = pattern.upcase

    if pattern == @key_code
      [:black, :black, :black, :black]
    else
      invalid_colors = pattern.tr(@key_code, '')
      pegs = [:white] * (pattern.length - invalid_colors.length)

      if @key_code[0] == pattern[0]
        pegs.shift
        pegs << :black
      end

      if @key_code[1] == pattern[1]
        pegs.shift
        pegs << :black
      end

      if @key_code[2] == pattern[2]
        pegs.shift
        pegs << :black
      end

      if @key_code[3] == pattern[3]
        pegs.shift
        pegs << :black
      end

      pegs      

    end
  end

end
$> ruby  game_engine_test.rb -n /test_0[1-9]/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
.........

Finished in 0.001037996 seconds.
------
9 tests, 9 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
8670.55 tests/s, 8670.55 assertions/s

Test: repeating same color produces correct feedback

While thinking about the tr method, we realized that it would do more than we expected… it is going to remove every occurrence of the colours, leading to a wrong number of correct colours. Say the key code is WBGY and the proposed pattern is WWWW, after the translation we get an empty string which seems to mean that the key code contains 4 white colours but the given pattern contains one white at the right position and three whites wrongly ordered, strange isn’t it?

Therefore, we added the 10th test to show the problem.

$> ruby  game_engine_test.rb -n /test_\\d\\d/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
.........F
===============================================================================
Failure: test_10_repeating_same_color_produces_correct_feedback(GameEngineTests)
/tmp/release/mastermind/game_engine_test.rb:48:in `test_10_repeating_same_color_produces_correct_feedback'
     45:   end
     46:   
     47:   def test_10_repeating_same_color_produces_correct_feedback
  => 48:     assert_equal [:black], @engine.check('GGGG')
     49:   end
     50:   
     51: end
<[:black]> expected but was
<[:white, :white, :white, :black]>

diff:
? [:white, :white, :white, :black]
===============================================================================


Finished in 0.00665286 seconds.
------
10 tests, 10 assertions, 1 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
90% passed
------
1503.11 tests/s, 1503.11 assertions/s

We must write our own translate method, which removes the right number of colours from the given pattern. We name the method remove_correct_colors, as it makes more sense.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  def remove_correct_colors pattern
    
    pattern_array = []
    
    pattern.each_char do |character|
      pattern_array << character
    end
    
    @key_code.length.times do |index|
      
      color = @key_code[index]
      
      position = pattern_array.index(color)
      
      if position
        pattern_array.delete_at(position)
      end
      
    end
    
    pattern_array.join
    
  end

Lines 3 to 7 convert the pattern to an array of characters named pattern_array.

For each character in the key_code, loop starting on line 9, we search if it appears in the pattern_array array (line 13), if so we remove the character (only once) from pattern_array (line 16).

After the loop (on line 21), pattern_array contains the characters (colours) that did not match and we pack them as a String.

We need to call the new method instead of tr to make the test pass.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class GameEngine

  def initialize key_code
    @key_code = key_code
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern

    return [] unless pattern.length == 4

    pattern = pattern.upcase

    if pattern == @key_code
      [:black, :black, :black, :black]
    else

      invalid_colors = remove_correct_colors(pattern)

      pegs = [:white] * (pattern.length - invalid_colors.length)

      if @key_code[0] == pattern[0]
        pegs.shift
        pegs << :black
      end

      if @key_code[1] == pattern[1]
        pegs.shift
        pegs << :black
      end

      if @key_code[2] == pattern[2]
        pegs.shift
        pegs << :black
      end

      if @key_code[3] == pattern[3]
        pegs.shift
        pegs << :black
      end

      pegs      

    end
  end

  def remove_correct_colors pattern
    
    pattern_array = []
    
    pattern.each_char do |character|
      pattern_array << character
    end
    
    @key_code.length.times do |index|
      
      color = @key_code[index]
      
      position = pattern_array.index(color)
      
      if position
        pattern_array.delete_at(position)
      end
      
    end
    
    pattern_array.join
    
  end

end
$> ruby  game_engine_test.rb -n /test_\\d\\d/
Loaded suite /tmp/release/mastermind/game_engine_test
Started
..........

Finished in 0.001194464 seconds.
------
10 tests, 10 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
8371.96 tests/s, 8371.96 assertions/s

Test: engine is not case sensitive (2)

We said earlier that the code converts the pattern to upper letters so that it is not case sensitive but when we set the key code (the secret code) we don’t do anything. We must store the key code as upper letters at creation time.

game_engine.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
class GameEngine

  def initialize key_code
    @key_code = key_code.upcase
  end
  
  # returns an array of :white and :black values, with :white(s) first
  def check pattern

    return [] unless pattern.length == 4

    pattern = pattern.upcase

    if pattern == @key_code
      [:black, :black, :black, :black]
    else

      invalid_colors = remove_correct_colors(pattern)

      pegs = [:white] * (pattern.length - invalid_colors.length)

      if @key_code[0] == pattern[0]
        pegs.shift
        pegs << :black
      end

      if @key_code[1] == pattern[1]
        pegs.shift
        pegs << :black
      end

      if @key_code[2] == pattern[2]
        pegs.shift
        pegs << :black
      end

      if @key_code[3] == pattern[3]
        pegs.shift
        pegs << :black
      end

      pegs      

    end
  end

  def remove_correct_colors pattern
    
    pattern_array = []
    
    pattern.each_char do |character|
      pattern_array << character
    end
    
    @key_code.length.times do |index|
      
      color = @key_code[index]
      
      position = pattern_array.index(color)
      
      if position
        pattern_array.delete_at(position)
      end
      
    end
    
    pattern_array.join
    
  end

end

The last test, in the second test case, is about this case.

$> ruby  game_engine_test.rb
Loaded suite /tmp/release/mastermind/game_engine_test
Started
...........

Finished in 0.001318925 seconds.
------
11 tests, 11 assertions, 0 failures, 0 errors, 0 pendings, 0 omissions, 0 notifications
100% passed
------
8340.13 tests/s, 8340.13 assertions/s