When two players initiate a game of tic-tac-toe, what is the first
thing they do? They draw a board. Therefore, we will worry about the board
first. A key object we will need is a Board
object that stores the tic-tac-toe board.
Think about the properties of such a board. A board is typically a large
square made up of 3 × 3 smaller squares. At the start of
the game all the little squares will be empty, but later they will be
filled in with X’s and O’s as the game progresses. Therefore, we will need
a 3 × 3 array that is initially empty and can be filled
in later while the game is being played. Using objects, we will be able to
design the board construction.
In Example 12-1, we use a
shorthand way of declaring an array and its members. Array.new(5)
{ 3
} creates an array of five elements, with
each value initialized to 3. Lines 8–10 declare an array (line 8), with
each element being an array initialized to EMPTY_POS
(line 9).
Using our knowledge of arrays and objects, we are able to set up the board constructor as shown in Example 12-1.
1
class
Board
2
3
BOARD_MAX_INDEX
=
2
4
EMPTY_POS
=
' '
5
6
def
initialize
(
current_player
)
7
@current_player
=
current_player
8
@board
=
Array
.
new
(
BOARD_MAX_INDEX
+
1
)
{
9
Array
.
new
(
BOARD_MAX_INDEX
+
1
)
{
EMPTY_POS
}
10
}
11
end
12
end
Line 3 defines the largest index (remember, array indices start at 0).
Line 4 defines what is considered an empty position, or a position not occupied by either an X or an O.
Lines 6–11 define the constructor for our board.
Line 7 assigns an instance variable that represents the current player.
Lines 8–10 create the board instance variable, a 3 × 3 array.
The main program (tictactoe.rb) will use the board class. The start of the main program is given in Example 12-2.
1
require_relative
'board.rb'
2
3
puts
"Starting tic-tac-toe..."
4
players
=
[
'X'
,
'O'
]
5
current_player
=
players
[
rand
(
2
)
]
6
b
=
Board
.
new
(
current_player
)
7
b
.
display
()
8
puts
Lines 4 and 5 randomly pick an initial player (either X or O).
Line 6 creates an instance of the Board
class and assigns it to b
.
Lines 7 and 8 display the initial blank board and output a fresh blank line for readability.
The display method (which is part of board.rb) is given in Example 12-3.
1
def
display
2
puts
"+- - - - - -+"
3
for
row
in
0
.
.
BOARD_MAX_INDEX
4
# print has to be used when we don't want to output a line break
5
"| "
6
for
col
in
0
.
.
BOARD_MAX_INDEX
7
s
=
@board
[
row
][
col
]
8
if
s
==
EMPTY_POS
9
col
+
(
row
*
3
)
+
1
10
else
11
s
12
end
13
" | "
14
end
15
puts
"
+- - - - - -+"
16
end
17
end
Line 2 prints a row of dashes (the top of the board).
Line 3 starts an outer loop that runs through each row.
Line 6 starts an inner loop that traverses each column.
Line 7 assigns the current cell to the variable s
.
Lines 8–12 print the number of the cell if it’s currently unoccupied or the current occupant if the cell is occupied.
Note the difference between print
and puts
: print
simply writes the characters passed to it
without writing an end of line upon completion. Thus, if one wishes to
continue writing to a given line, print
would be used. Remember, however, that if using print
and a new line is desired, the newline
character (
) must be entered, as
shown in line 15. This is in contrast to puts
, which automatically inserts the newline
character every time.
Returning to our main program, the key loop that runs the game will be implemented. The code is presented in Example 12-4. (The code begins at line 1; however, this is a continuation of tictactoe.rb.)
1
while
not
b
.
board_full
()
and
not
b
.
winner
()
2
b
.
ask_player_for_move
(
current_player
)
3
current_player
=
b
.
get_next_turn
()
4
b
.
display
()
5
puts
6
end
7
8
if
b
.
winner
()
9
puts
"Player "
+
b
.
get_next_turn
()
+
" wins."
10
else
11
puts
"Tie Game."
12
end
13
puts
"Game Over"
Line 1 starts a loop that continues as long as the board is not full and there is no winner.
Line 2 prompts the current player for a move.
Line 3 gets the next player.
Lines 4 and 5 display the current board.
Lines 8–13 print the winner’s name if there was a winner, or “Tie Game” if the game ended in a tie.
The loop ends when there is a winner or there is a full board
detected. At this point, you can see we need to discuss the board_full
method, the winner
method, and the get_next_turn
method. The code presented in
Example 12-5 is for the board_full
method. This method determines if the
board is full, meaning that no more pieces may be placed.
1
def
board_full
2
for
row
in
0
.
.
BOARD_MAX_INDEX
3
for
col
in
0
.
.
BOARD_MAX_INDEX
4
if
@board
[
row
][
col
]
==
EMPTY_POS
5
return
false
6
end
7
end
8
end
9
# Since we found no open positions, the board is full
10
return
true
11
end
Line 4 checks each cell to see if it is unoccupied. If at least one cell is unoccupied, the board is not full and the game must continue.
Line 10 returns true
in the
event that none of the cells are open for possible capture.
The winner
method is more
complex. Essentially, we must check every row, every column, and every
diagonal for a winner. The method is presented in Example 12-6.
1
def
winner
2
winner
=
winner_rows
()
3
if
winner
4
return
winner
5
end
6
winner
=
winner_cols
()
7
if
winner
8
return
winner
9
end
10
winner
=
winner_diagonals
()
11
if
winner
12
return
winner
13
end
14
# No winners
15
return
16
end
The winner
method uses the helper
methods winner_rows
, winner_cols
, and winner_diagonals
, which return the
player’s symbol if the player won by connecting the rows, columns, or
diagonals with her or his pieces, respectively. If winner
is set, then we know that the current
value of winner
is the player who won
the game. Otherwise, we return nothing, signifying there is no winner
yet.
The methods winner_rows
, winner_cols
, and winner_diagonals
are good examples of class
methods, as explained in Chapter 9. They are all fairly
straightforward, as they all look for three of the same values with regard
to their given task. The method winner_rows
is shown in Example 12-7.
1
def
winner_rows
2
for
row_index
in
0
.
.
BOARD_MAX_INDEX
3
first_symbol
=
@board
[
row_index
][
0
]
4
for
col_index
in
1
.
.
BOARD_MAX_INDEX
5
if
first_symbol
!=
@board
[
row_index
][
col_index
]
6
break
7
elsif
col_index
==
BOARD_MAX_INDEX
and
first_symbol
!=
EMPTY_POS
8
return
first_symbol
9
end
10
end
11
end
12
return
13
end
Aside from being able to return values, the return
keyword immediately stops execution of
the current method and returns to the one from which it was called. This
is used heavily in the winner
method.
If the winner is found in the rows, the columns and diagonals are not
searched. Likewise, if the winner is found in the columns, the diagonals
are not searched.
Line 2 begins an outer loop to look for a winner across a row.
For each row, all the columns are checked. The variable first_symbol
contains the symbol that must
match.
Line 3 initializes first_symbol
for row 0.
Lines 4–10 provide an inner loop that looks at all elements in
the given column. If a cell does not match the first_symbol
value, then it is not a winning
combination.
For example, if the first_symbol
value was initially an O and
now we encounter an X in the same column, then this is not a winning
combination. If we reach the end of the columns, we have a winner, and
we return the winner as its name is in the
first_symbol
column.
Line 7 contains a final check to make sure we have not found
three empty positions in a column. If we do not return a winner, then
we simply return on line 12 (this is essentially returning a nil
or false
value) to the caller of this
method.
The next method, presented in Example 12-8, is very similar in that it looks for a winning column. This time, a given column is checked, and we travel down the column checking to see if we have all matching symbols.
1
def
winner_cols
2
for
col_index
in
0
.
.
BOARD_MAX_INDEX
3
first_symbol
=
@board
[
0
][
col_index
]
4
for
row_index
in
1
.
.
BOARD_MAX_INDEX
5
if
first_symbol
!=
@board
[
row_index
][
col_index
]
6
break
7
elsif
row_index
==
BOARD_MAX_INDEX
and
first_symbol
!=
EMPTY_POS
8
return
first_symbol
9
end
10
end
11
end
12
return
13
end
Finally, we look for a win across a diagonal. This is inherently
more difficult because it requires a backward traversal of the columns.
This is done with the winner_diagonals
method shown in Example 12-9.
1
def
winner_diagonals
2
first_symbol
=
@board
[
0
][
0
]
3
for
index
in
1
.
.
BOARD_MAX_INDEX
4
if
first_symbol
!=
@board
[
index
][
index
]
5
break
6
elsif
index
==
BOARD_MAX_INDEX
and
first_symbol
!=
EMPTY_POS
7
return
first_symbol
8
end
9
end
10
first_symbol
=
@board
[
0
][
BOARD_MAX_INDEX
]
11
row_index
=
0
12
col_index
=
BOARD_MAX_INDEX
13
while
row_index
<
BOARD_MAX_INDEX
14
row_index
=
row_index
+
1
15
col_index
=
col_index
-
1
16
if
first_symbol
!=
@board
[
row_index
][
col_index
]
17
break
18
elsif
row_index
==
BOARD_MAX_INDEX
and
first_symbol
!=
EMPTY_POS
19
return
first_symbol
20
end
21
end
22
return
23
end
Line 2 initializes our search with the upper-lefthand corner of the board.
Lines 3–9 traverse the diagonal from the top left to the bottom right, continuing as long as there is a match.
Line 10 sets the initial value to the top right.
Lines 11–20 check the diagonal from the top right to the bottom left. If no matches are found, we return nothing (line 22).
The only methods we have not yet described are the ask_player_for_move
method and the validate_position
method, which simply prompt
the user for a move and ensures that the move is allowed. The ask_player_for_move
method is presented in Example 12-10.
1
def
ask_player_for_move
(
current_player
)
2
played
=
false
3
while
not
played
4
puts
"Player "
+
current_player
+
": Where would you like to play?"
5
move
=
gets
.
to_i
-
1
6
col
=
move
%
@board
.
size
7
row
=
(
move
-
col
)
/
@board
.
size
8
if
validate_position
(
row
,
col
)
9
@board
[
row
][
col
]
=
current_player
10
played
=
true
11
end
12
end
13
end
Line 3 starts a loop that keeps processing until a valid move is
obtained. The flag played
is
initially set to false
.
Line 4 asks the user for her or his move.
Line 5 obtains the user’s response with a call to gets
, which obtains a string and then
converts it to an integer.
Line 6 converts the number 1–9 into column number 0–2, and line 7 converts it into a row number. These conversions stem from the equation that took each cell of the array and assigned a cell number. Convince yourself that lines 6 and 7 are correct.
Line 8 calls another internal method, validate_position
, which is a method that
makes sure the user chooses a spot on the board and a spot that is not
already taken. If a valid position is obtained, then line 10 sets the
played
flag to be true
, and the loop will end when line 3 is
encountered. For an invalid move, the played
flag is not set to true
, so the loop will continue
again.
The validate_position
method is
given in Example 12-11.
1
def
validate_position
(
row
,
col
)
2
if
row
<=
@board
.
size
and
col
<=
@board
.
size
3
if
@board
[
row
][
col
]
==
EMPTY_POS
4
return
true
5
else
6
puts
"That position is occupied."
7
end
8
else
9
puts
"Invalid position."
10
end
11
return
false
12
end
Line 2 makes sure the row and column are within the range of the
board. We know it’s a three row by three column game, but by using the
size
variable we could easily
expand this to larger board sizes for other games, like Connect Four.
In this case the size is three elements, 0, 1, and 2. The size was
established earlier, but this will allow us to easily change the size
of the board.
Line 3 checks to make sure the user has selected a position that
was previously empty. If so, true
is returned, and we are done.
Lines 5–7 handle the case for when a user selects an occupied position.
Lines 8–10 handle the case for when a user selects a position off the board.
Line 11 returns false
, which
indicates that an invalid move occurred. Note that line 11 is reached
only when an invalid move is attempted.
Finally, we need to discuss the get_next_turn
method, and we are done. It is
very simple and is shown in Example 12-12.
1
def
get_next_turn
2
if
@current_player
==
'X'
3
@current_player
=
'O'
4
else
5
@current_player
=
'X'
6
end
7
return
@current_player
8
end
Line 2 checks to see if we were using an X, and if so, we change
to the O
in line 3; otherwise, it was an
O
, so in line 5 we turn it into an X.
At this point, we have created a working game of tic-tac-toe that you can play against yourself or a friend. The code written for this game encompasses almost all the topics covered in this book. If you understood it all, give yourself a pat on the back. If you are frustrated with this chapter and do not understand all the ideas presented, it is a good idea to go back to previous chapters and play around with the Ruby concepts presented in those chapters.
Don’t get too comfortable if you’ve done well thus far. The next section will add artificial intelligence to our tic-tac-toe game, enabling you to play against the computer.
13.58.5.57