mutable struct TicTacToe
::Matrix
board::String
current_move::Bool
game_over::Int
move_countfunction TicTacToe()
= rand(["X", "o"])
first_move println("$first_move's goes first!")
return new(
reshape([" " for i in 1:9], 3, 3),
first_move,false,
1
)end
end
As I am primarily a python developer, I was suprised by how easy Julia has been to pick up for scripting and day to day data science work. The DataFrames.jl package feels like a love child between pandas and R, and basic langauge features like control flow, etc have felt very natural. Adding the end
keyword hasn’t been a difficult transition, but the 1-based indexing has definitely bit me a few times!
Basic scripting and analysis work isn’t software development, and I have been meaning to dive into some of the more foundational aspects that make Julia unique. As such, I decided to write a quick Tic-Tac-Toe implementation in Julia.
The basic setup
The clear cut pythonic implementation of Tic-Tac-Toe would be to use a class. Since a game of Tic-Tac-Toe requires us to record the state of the game after each player moves, a class provides a nice “bundled” way for us to provide encapsulation (bundling data with methods). In theory object-oriented design also can provide modularity via various design patterns. In practice I’ve seen this break down more often than not, with classes often becoming “god objects”. This isn’t always a bad thing. A Pandas DataFrame is a ubuiquitous and highly productive god object, that also has been designed to interface nicely with many other libraries. I’ve also seen OO patterns done really well such as scikit-learn’s various MixIn
patterns. Anyways, this isn’t a blog post about OO patterns, so I digress.
Let’s dive into the basic high level overview of my implementation of Tic-Tac-Toe in Julia.
Julia doesn’t have classes. Instead it uses structs to provide composite types bound to a single object. A few interesting observations about the code above:
- Embarassingly it took me awhile to figure out how constructors work in structs. Essentially, you just define a function of the same name as the struct, and it will instantiate an object with the defaults specified within the
new
keyword. - Note when a struct is defined it creates a new type that Julia recognizes. The interesting thing about this is it allows us to harness the power of multiple dispatch. This is a core difference coming from a langauge like python. In python you often do things like
some_object.some_method()
, whereas is Julia the pattern issome_method(some_object)
. The benefit of the Julia way of doing things is that we can have N definitions ofsome_method
that are dispatched depending on the type of the arguments. While this might not seem beneficial at first, it is actually hugely powerful since it frees us from needing to know the internals of objects and enables a ton of code reuse that is hard to replicate in an OO paradigm. - The
board
,current_move
,game_over
, andmove_count
fields are all the core components of the game. We need a board to move around on that stores the state of the game,current_move
simply keeps track of which player’s turn it is,game_over
is a flag for when to terminate the game, andmove_count
is some data I keep around to make it easier to tell when the game is a draw (more to come on that).
Ok so that’s the data we need to keep track of, but it’s not the actual game flow. Tic-Tac-Toe is potentially the simplest “board game” known to man. Every 4 year old can competently play the game. The game control flow reflects this simplicity:
function play()
= TicTacToe()
game while !game.game_over
println("Enter a move (e.g. '1, 2')")
= readline()
user_move = parse_input(user_move)
x, y move!(game, x, y)
end
end
play (generic function with 1 method)
The function above is short and sweet. It instantiates a game of Tic-Tac-Toe, and while the game is “not over” it queries for user input, and then mutates the state of the game by making a move. The bulk of the complexity is hidden in the single function move!
. Note in Julia the !
at the end of the function is convention that signifies that the function mutates it’s argument rather than returning a copy.
Making moves and checking win criteria
The move!
function in turn is also very basic. It implements the following: - If the player move is already taken, warn and return - Otherwise set the selected move on the board - Check if the new move results in a win or a draw - If it does not result in a terminal game state, switch the current player - Print the game board
The real meat of the program lies in checking the terminal state conditions in check_win_or_draw
. This part was actually more interesting than I thought it would be. It turns out there are several sufficient criteria for determining whether a player has won the game. One particularly interesting sufficient criteria is the magic squares criteria. A magic square is a square matrix whose elements sum up to a constant along rows, columns, and both the diagonal and antidiagonal. We can actually harness this along with some clever masking to quickly determine if a player has won the game. This saves us from needing to tabulate the occurance of items along each potential “win axis” and instead simplifies the win criteria to a sum assertion.
Another hack I took to determine whether a draw occurred was to simply keep track of the number of move that have taken place. I think a sufficient condition for a draw is that the number of moves made reaches N-1 without a win. Although I didn’t bother to do the combinatorial proof of that hunch it seems to work out in the case of a 3x3 game board.
import LinearAlgebra:diag
import Base:display
function move!(game::TicTacToe, x::Int, y::Int)
if game.board[x, y] != " "
@warn "Move already taken! Select another move."
return
end
= game.current_move
game.board[x, y] check_win_or_draw(game)
+= 1
game.move_count switch!(game, game.current_move)
display(game)
end
function check_win_or_draw(game::TicTacToe)
# A sufficient condition for winning is accumulating a sum of 15
# along any axis/diagonal of the 3x3 "magic square"
= reshape([8, 1, 6, 3, 5, 7, 4, 9, 2], 3, 3)
magic_square # This is the masking procedure
findall(x -> x != game.current_move, game.board)] .= 0
magic_square[= game.current_move
current_move
= 15
magic_square_win_sum = prod(size(magic_square)) - 1
draw_criteria
# Check row-wise potential wins
for i in 1:3
if sum(magic_square[i, :]) == magic_square_win_sum
= true
game.game_over println("Game Over! $current_move's wins!")
return
end
end
# Check column-wise potential wins
for i in 1:3
if sum(magic_square[:, i]) == magic_square_win_sum
= true
game.game_over println("Game Over! $current_move's wins!")
return
end
end
# Check diagonal wins
if sum(diag(magic_square)) == magic_square_win_sum ||
sum(diag(reverse(magic_square, dims=1))) == magic_square_win_sum
= true
game.game_over println("Game Over! $current_move's wins!")
return
end
# Finally check to ensure that the game is not a draw
if game.move_count == draw_criteria
println("Game over: draw.")
= true
game.game_over return
end
end
function switch!(game::TicTacToe, current_move::String)
if current_move == "X"
= "o"
game.current_move else
= "X"
game.current_move end
end
function parse_input(s::String)
= [parse(Int, i) for i in split(s, ",")]
x, y return x, y
end
# Yay multiple dispatch!
function display(game::TicTacToe)
= reshape(game.board, 9)
b println("""
-------------
| $(b[1]) | $(b[4]) | $(b[7]) |
-------------
| $(b[2]) | $(b[5]) | $(b[8]) |
-------------
| $(b[3]) | $(b[6]) | $(b[9]) |
-------------
""")
end
display (generic function with 31 methods)
Ok! So now we should have a fully working instance of Tic-Tac-Toe in Julia. Let’s play a round against ourselves (how exciting).
play()
o's goes first!
Enter a move (e.g. '1, 2')
-------------
| o | | |
-------------
| | | |
-------------
| | | |
-------------
Enter a move (e.g. '1, 2')
-------------
| o | | |
-------------
| | X | |
-------------
| | | |
-------------
Enter a move (e.g. '1, 2')
-------------
| o | | |
-------------
| | X | |
-------------
| | | o |
-------------
Enter a move (e.g. '1, 2')
-------------
| o | | X |
-------------
| | X | |
-------------
| | | o |
-------------
Enter a move (e.g. '1, 2')
-------------
| o | o | X |
-------------
| | X | |
-------------
| | | o |
-------------
Enter a move (e.g. '1, 2')
Game Over! X's wins!
-------------
| o | o | X |
-------------
| | X | |
-------------
| X | | o |
-------------
stdin> 1,1
stdin> 2,2
stdin> 3,3
stdin> 1,3
stdin> 1,2
stdin> 3,1
So there you have it: Tic-Tac-Toe in pure Julia. In no way am I claiming this is the optimal Julia implementation of the game, but hopefully it illustrated some of the core ideas that makes Julia a bit different than more traditional languages like python.