Putting It All Together: Tic-Tac-Toe
12.1 Introduction
It is finally time to put everything you know all together. You will no longer use programming elements in isolation; writing programs rarely involves using only if statements, loops, or objects. Typically, all programming elements are interleaved. For clarity, we described these elements in isolation; now we integrate them. The challenge is to create a task that requires integration that is not too simple to be uninteresting but not so complex that it requires 500 pages of text.
After much thought, we decided to develop the simple, well-known game of tic-tac-toe. The idea is to initially develop a program that facilitates two people playing against each other, and then to enhance the program so that a person can play against a computer. As correct play guarantees at least a tie, we provide sufficient algorithm detail to guarantee that the computer will never lose. We provide sufficient code segments to illustrate the development of the program but leave it to you to complete it. Good luck; you are ready for it.
Using what you have learned so far, it is time to put all of your knowledge together into one fun program. The game of tic-tac-toe is a classic, and the rules can be easily looked up if you are not familiar with them. First you will learn how to build a Ruby program that will allow two users to play the game. After this program has been established, we will extend the program to allow a user to play against the computer.
Our tic-tac-toe game will not have a nice user interface. User interfaces are a more advanced topic that is beyond the scope of this book. We want you to focus on problem solving in this course and not on figuring out how to create a pretty user interface.
Our board will be drawn with simple X’s and O’s and will be invoked from the command line of your operating system.
We will first talk about how to approach the problem, and then how to implement the solution in an incremental fashion.
Gem of Wisdom
If you can write a tic-tac-toe game in Ruby that works, most people would agree that you know Ruby and that you have a clue about programming. It is not the easiest thing to do. If you grasp everything in this chapter, then you are ready to move beyond just an introduction to computer science.
12.2 Programming Approach
First we need to break the problem into key steps. Many programmers are tempted to run to the computer and start writing code immediately and then debug their code. This is not recommended.
It is strongly suggested that you step away from the computer, grab paper and pencil, and write out the key algorithms that will be needed to solve the problem. As part of this effort, you should be able to describe the key objects and methods that will be involved.
The key steps of a tic-tac-toe game are rather straightforward. Until the board is full and there is no winner, do the following:
Draw the current board.
Ask the player for a move.
Make sure the move is valid (e.g., you can’t move to a square that is already taken).
Store the current move.
You should write out the key objects and the key methods and then implement them gradually.
12.3 Tic-Tac-Toe
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.
Gem of Wisdom
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.
Initial board and constructor
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.
Beginning of tictactoe.rb
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 tob
.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.
Display method for Board class
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
"
\n
+- - - - - -+"
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 (\n
) 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
.)
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.
board_full method
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.
Winner method
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.
winner_rows method
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
Gem of Wisdom
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 thefirst_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
orfalse
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.
winner_cols method
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.
winner_diagonals method
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.
ask_player_for_move method
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 tofalse
.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 theplayed
flag to betrue
, and the loop will end when line 3 is encountered. For an invalid move, theplayed
flag is not set totrue
, so the loop will continue again.
The validate_position
method is
given in Example 12-11.
validate_position method
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.
get_next_turn_method
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 anO
, 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.
12.4 Tic-Tac-Toe Revised
Although a player-versus-player version of tic-tac-toe is nice, chances are you will not have a friend who wants to spend time playing computerized tic-tac-toe with you for long. Perhaps it will be more satisfying if you create a version of tic-tac-toe that will play against you. This is what we will do in this section.
First, let’s understand the change we are trying to make. Instead of always having the player input the position on the board to fill, we want the computer to take one of the turns. Therefore, we will make a clearer distinction between the human’s move and the computer’s move. The code in Example 12-13 illustrates this change.
Revised ask_player_for_move method
1
def
ask_player_for_move
(
current_player
)
2
if
current_player
==
COMPUTER_PLAYER
3
computer_move
(
current_player
)
4
else
5
human_move
(
current_player
)
6
end
7
end
The purpose of the ask_player_for_move
method is to switch between
player input and computer AI (artificial intelligence) based on the
current player. If the current player is the computer (line 2), let the
computer take its move (line 3); otherwise (line 4), let the user take her
or his move (line 5). Make sure to define the constant COMPUTER_PLAYER
. It may be set equal to
X
or O
. This would also be a good time to define the
constant HUMAN_PLAYER
. This should be
set equal to O
if COMPUTER_PLAYER
is equal to X
, or X
if
COMPUTER_PLAYER
is equal to O
.
If you have been reading carefully, you will have noticed that
ask_player_for_move
was already defined
pages ago, and now we have changed its definition here. Previously, the
method prompted either player one or player two for which turn she or he
wanted to make, and then took the turn if it was valid. That code was
relocated to the method human_move
. The
code for human_move
is identical to our
old ask_player_for_move
method.
At this point, we have a mechanism for changing between the human’s
and the computer’s turn, and we have slightly changed the definition of
the human’s turn. From here it should be clear that the next logical step
is to define the computer_move
method,
and it is defined in Example 12-14.
computer_move method
1
def
computer_move
(
current_player
)
2
row
=
-
1
3
col
=
-
1
4
found
=
"F"
5
6
check_rows
(
COMPUTER_PLAYER
,
found
)
7
check_cols
(
COMPUTER_PLAYER
,
found
)
8
check_diagonals
(
COMPUTER_PLAYER
,
found
)
9
10
check_rows
(
HUMAN_PLAYER
,
found
)
11
check_cols
(
HUMAN_PLAYER
,
found
)
12
check_diagonals
(
HUMAN_PLAYER
,
found
)
13
14
if
found
==
"F"
15
if
@board
[
1
][
1
]
==
EMPTY_POS
16
row
=
1
17
col
=
1
18
@board
[
row
][
col
]
=
current_player
19
elsif
available_corner
()
20
pick_corner
(
current_player
)
21
else
22
until
validate_position
(
row
,
col
)
23
row
=
rand
(
@board
.
size
)
24
col
=
rand
(
@board
.
size
)
25
end
26
@board
[
row
][
col
]
=
current_player
27
end
28
end
29
end
If this method looks complicated, don’t worry. First, let’s walk
through the code at an algorithmic level. The computer_move
method does the following in
order, picking the first rule it can successfully complete:
Check the rows, columns, and diagonals to see if either the computer can win or the human can win. If such a spot exists, take it to either win the game or prevent the human from winning. Note that we intentionally do not include the code for those methods, as their implementation is straightforward and their details are unnecessary for the purposes of our discussion.
If the middle cell is unoccupied, take the middle cell.
If there is an available corner, take any of the available corner spots.
If none of the prior conditions are true, pick a random cell.
For simplicity, we did not include all the necessary code to guarantee at least a draw for the AI. However, in tic-tac-toe, correct play guarantees at least a draw. To guarantee at least a draw, the computer’s corner selection option should be modified as follows:
If a corner spot is available, then select a corner spot, with the following corner selection preference. If the computer went first, randomly choose among the available corners that are not adjacent to the human’s noncenter spot(s). If the human went first, then check for one exception condition; otherwise, randomly choose among the available corners that are adjacent to the human’s noncenter spot(s). The exception condition is one in which only three squares are occupied, the computer has the center square, and the human has both corners on the same diagonal; in such a case, override the corner selection option and randomly choose a noncorner spot. If none of the aforementioned conditions are met, then choose any available corner.
This description defines a high-level algorithm for the game of tic-tac-toe. You now have all the needed skills to design, develop, and debug all the implementation specifics. Please do so and enjoy playing your computerized opponent.
12.5 Summary
Now that we have walked through a more detailed example, you should be able to implement some fun games in Ruby. Games like blackjack and poker are now all within your grasp.
We have combined the concepts covered in the previous chapters in this example. With the file processing we discussed in Chapter 11, you can even make it so that you can save the state of these games or the current high score to a file. These concepts are common among most programming languages. So, as a programmer, it is much more effective to learn these concepts and how they can be used to solve certain problems than to direct your focus onto learning syntax, which is just the tool that allows us to implement our ideas.
The best way to become more comfortable with these types of problems is to practice doing them. Take your time with the following problems, draw out what the problem requires, and design a solution. Eventually, this process becomes much easier, and you will be able to implement the solution in any language.
12.6 Exercises
Give a detailed explanation of the relationship between the board and the tic-tac-toe objects.
Design and implement a game of “pick a number.” This is a simple guessing game where you think of a number between 1 and n in your head (don’t let the computer know this number). Tell the computer what n is, and then let the computer guess the number. If the guess is too low, you should let the computer know that the guess was too low; likewise, if the guess was too high you should also let the computer know. The computer can take multiple turns (hint: it should take log n turns), but simply guessing every number between 1 and n is far from an acceptable answer. Implement this program two different ways: procedurally and object-oriented.
Design and implement a simplified version of the card game blackjack. The rules are as follows:
A standard 52-card deck is used and shuffled well.
The cards have the following values: 2 to 10 are the value of the card, jack to king have a value of 10, and ace has a value of 1 or 11.
Only you are playing, trying to get the cards to add up to 21 without going over 21. If you go over 21, you lose.
Initially you are dealt two cards. If you are dissatisfied with these cards, you can ask for more. It is not a trade; additional cards are added. If you feel like you have enough cards and do not want to bust, you can “stay” and end this round of blackjack.
After the game has ended, the user should be able to play again, if she or he chooses to do so. If so, the game will restart like a brand-new game. Implement this in an OO fashion. If you split the work among your objects properly, you will be able to reuse a significant chunk of code on the next problem.
Add scoreboard functionality to Exercise 3. To do this, you need to make a few game modifications. First, you need to ask for the user’s name at the beginning of every game. Next, you need the ability to view the scoreboard during a game; this should be done directly after prompting for a name. If a user’s name already exists on the scoreboard, you will modify that user’s score. Otherwise, the new user will be added to the scoreboard in the proper position. The scoreboard needs to remember scores even after the program is exited (hint: write scores out to a file). If a score surpasses another score, the ordering of scores on the scoreboard needs to change. The highest score should be listed first, the lowest score last. The scoring criteria are defined in Table 12-1.
Table 12-1. Blackjack scoring criteria Total card value Points given Bust
–15
21
40
20
30
19
20
18
10
17
5
16
1
< 16
0
Being able to read and modify other people’s code is essential to being a successful computer scientist. Modify
tictactoe.rb
andboard.rb
to play tic-tac-toe on boards of size 3 × 3, 6 × 6, and 9 × 9 based on the user’s choice. Adjust everything so that the game works as well on 6 × 6 and 9 × 9 boards as it does on 3 × 3 boards.The current AI for the tic-tac-toe game is not perfect. Implement the behavior discussed in this chapter’s summary to guarantee at least a draw, and ensure its correctness.