Introduction
In this tutorial series, we are going to write a web-app that enables 2 players to play the Tic-Tac-Toe game online. It will be leveraging the OTP platform to make the app scalable and fault tolerant and consist of the following elements:
- Tic-Tac-Toe engine - A separate elixir app that houses all our game logic
- Backend - A Phoenix web-app that will serve our frontend and handles the communication between the frontend and the game engine
- Frontend - The frontend will be written in Vue.JS
Tutorial series
- Creating the game engine [Part 1]
- Wrap it up in a server (GenServer & Supervision) [Part 2]
- Building a web interface with Phoenix [Part 3]
- Setting up Vue.js with Phoenix [Part 4]
- Creating the Vue.js frontend [Part 5]
- Real-time Tic-Tac-Toe with Phoenix Channels [Part 6]
Table of contents
- Introduction
- Creating the project
- Defining the Game module
- Taking a turn
- Game status: Playing, Win & Draw
- Final touch
Creating the project
Let’s get right to it. Open up the terminal and create a new mix project:
mix new tic_tac_toe --umbrella
> mix new tic_tac_toe --umbrella
* creating .gitignore
* creating README.md
* creating mix.exs
* creating apps
* creating config
* creating config/config.exs
Your umbrella project was created successfully.
Since we are going to make at least 2 different apps (game engine & web app), we add the --umbrella
flag to generate an umbrella project.
> cd tic_tac_toe/apps
> mix new engine
* creating README.md
* creating .gitignore
* creating mix.exs
* creating config
* creating config/config.exs
* creating lib
* creating lib/engine.ex
* creating lib/engine/application.ex
* creating test
* creating test/test_helper.exs
* creating test/engine_test.exs
Your Mix project was created successfully.
Defining the Game module
How are we going to define the board structure? Coming from an imperative language like Java, you say: Easy! We will use a 1 or 2-dimensional array to represent our grid. However, Elixir is not imperative, but functional. In fact, it doesn’t have support for arrays at all!
No problem, we can just use lists and retrieve an element with the Enum.at(list, index)
function.
Note this operation takes linear time. In order to access the element at index
index
, it will need to traverseindex
previous elements.
Enum.at Docs
As you can read above, random access on a linked list is O(n), we can do better. See, we do need arrays! You might be yelling out loud. Ask yourself: Do we really need arrays?
- Iterate sequentially -> Linked list works fine
- Access randomly -> Use a map
The answer is no, we don’t really need arrays. Maps provide us with fast lookup times and they are a fine solution to deal with grids in a functional language like Elixir.
Now we have this part out of the way, it becomes clear how we should structure the data of our grid-based game, with maps.
We will have 2 different maps: x
& o
, containing all the taken cells of player 1 and 2. This is all we need for now. Our game module will look like this :
The Board.new
function will initialize the board for us.
Wait a moment… We decided to use maps, what are those MapSet
’s?
Sets can’t contain duplicate items. A MapSet uses a Map behind the scenes and simply puts a dummy value when you add a new key: Map.put(key, @dummy_value)
.
For more info on MapSet’s, see the docs
For our simple game engine, we need two public functions to make our game playable.
A take turn
function, where a player takes a turn, and a game status
function. The latter we need so we can know if the game has been finished, if it was a draw, who won the game or if the game is still ongoing.
In the following chapters we are going to work out these 2 functions, so we get a playable Tic-Tac-Toe game in the end. Stay tuned!
Taking a turn
We create a take_turn
function that looks like this:
def take_turn(game, player_symbol, cell)
game
- Our game stateplayer_symbol
- The symbol of the player that is in turn, can be either be:x
or:o
cell
- The cell needs to be a tuple in the following form:{col, row}
The method will return:
{:ok, game}
- When it succeeded in making the turn.{:error, reason}
- When it failed to make the turn.{:error, :out_of_bounds}
- When the cell is out of bounds{:error, :already_taken}
- When the cell is already taken
Testing
TDD is a trending thing to do in our modern agile way of working, and not without reason. The idea of this development method is to write your tests before writing the actual code. This way we avoid writing tests that are influenced heavily by our already written code. I always think it is a bit boring to write tests (don’t we all) and it can feel so cumbersome at times. But after making it part of your developing cycle, writing and using these test will be a breeze.
Above all, Elixir is a joy to test. Duo its functional nature, having immutable data, it is dead easy to write tests for those, often small, functions. Something goes in & something goes out, plain & simple. We will be using ExUnit to write our tests. It is a solid testing framework that is easy to pick up.
Let’s try to adhere the following phrase better soon than late
instead of better late than never
when it comes to testing ;)
We can run these tests with the mix test
command.
> mix test /test/engine/game_test.ex
Finished in 0.04 seconds
3 tests, 3 failures
This doesn’t look so good, we are going to implement some code in the next paragraph.
Implementing the ‘take_turn’ function
def take_turn(%Game{turns: turns} = game, player_symbol, cell) do
cond do
cell_taken?(turns, cell) ->
{:error, :already_taken}
not within_bounds?(cell) ->
{:error, :out_of_bounds}
true ->
{:ok, update_in(game.turns[player_symbol], &MapSet.put(&1, cell))}
end
end
We didn’t implement the cell_taken?
and within_bounds?
functions yet, we will do that soon, but first lets have a look at the code shown above.
cond do
condition1 ->
# ...
condition2 ->
# ...
true ->
# ...
end
The cond
control flow structure shown here, is the equivalent of if {} else if {} else {}
like you would see in imperative languages.
{:ok, update_in(game.turns[player_symbol], &MapSet.put(&1, cell))}
The kernel’s update_in function, can update a nested structure. It takes a function as the second argument.
Here we pass the MapSet.put/2
function, so that we can add the new turn to our set.
# This is the shorter version
&MapSet.put(&1, cell)
# Of this
fn player_turns ->
MapSet.put(player_turns, cell)
end
For more information, have a look at: Partials and function captures
iex(1)> nested_map = %{ test1: %{ test2: %{ test3: %{ test4: 10 }}}}
%{test1: %{test2: %{test3: %{test4: 10}}}}
iex(2)> update_in(nested_map[:test1][:test2][:test3][:test4], fn x -> x + 5 end)
%{test1: %{test2: %{test3: %{test4: 15}}}}
The 2 missing functions:
@board_range 0..2
def within_bounds?({c, r}) do
c in(@board_range) && r in(@board_range)
end
defp cell_taken?(turns, cell) do
turns
|> Map.values
|> Enum.any?(&Enum.member?(&1, cell))
end
The within_bounds?
function is self-explanatory, we destructure the tuple into a column & row, then we check if those are in the @board_range
(0..2).
For cell_taken?
, we iterate through every entry in the turns
map. Those entries will contain the x
and o
turns. We check if any of those two sets contains the cell. If so, the cell is taken and it will return true
, if not, false
.
Now if we run our tests again:
> mix test test/engine/game_test.exs
Finished in 0.04 seconds
3 tests, 0 failures
Game status: Playing, Win & Draw
We need to know the status of the game. This status will tell us if the game is still playing, or if it has finished. If it is finished, it will also tell us what the result is.
def status(game)
The method will return:
{:finished, {:won, player}}
- If the game is finished{:finished, :draw}
- If the game is finished and it is a draw:playing
- If the game is still playing
Testing
Now we have defined what the output of our status
function looks like, it’s time to do write some basic tests:
test "player 'x' wins the game with a horizontal line" do
game = Game.new
game = make_turns(game, [{0,0}, {1,0}, {2,0}], :x)
assert Game.status(game) == {:finished, {:won, :x}}
end
test "player 'x' wins the game with a vertical line" do
game = Game.new
game = make_turns(game, [{0,0}, {0,1}, {0,2}], :x)
assert Game.status(game) == {:finished, {:won, :x}}
end
test "player 'x' wins the game with a diagonal line" do
game = Game.new
game = make_turns(game, [{0,0}, {1,1}, {2,2}], :x)
assert Game.status(game) == {:finished, {:won, :x}}
end
test "players play a draw" do
game = Game.new
{:ok, game} = Game.take_turn(game, :x, {0, 0})
{:ok, game} = Game.take_turn(game, :o, {2, 1})
{:ok, game} = Game.take_turn(game, :x, {2, 2})
{:ok, game} = Game.take_turn(game, :o, {1, 1})
{:ok, game} = Game.take_turn(game, :x, {0, 1})
{:ok, game} = Game.take_turn(game, :o, {0, 2})
{:ok, game} = Game.take_turn(game, :x, {2, 0})
{:ok, game} = Game.take_turn(game, :o, {1, 0})
# Not a draw yet ...
assert Game.status(game) != :{:finished, :draw}
{:ok, game} = Game.take_turn(game, :x, {1, 2})
assert Game.status(game) == {:finished, :draw}
end
test "game status is playing when the game is not finished yet" do
game = Game.new
assert Game.status(game) == :playing
{:ok, game} = Game.take_turn(game, :x, {0, 0})
{:ok, game} = Game.take_turn(game, :o, {2, 1})
{:ok, game} = Game.take_turn(game, :x, {2, 2})
assert Game.status(game) == :playing
end
def make_turns(game, turns, player) do
Enum.reduce(x_turns, game, fn cell, game ->
{:ok, game} = Game.take_turn(game, player, cell)
game
end)
end
Implementing the ‘status’ function
def status(%Game{turns: turns}) do
cond do
player_won?(turns[:x]) ->
{:finished, {:won, :x}}
player_won?(turns[:o]) ->
{:finished, {:won, :o}}
draw?(turns) ->
{:finished, :draw}
true ->
:playing
end
end
We are almost there, the last thing left to do is implementing draw?
and player_won?
. After this, our game should be playable!
Starting with the easiest of the two first:
defp draw?(turns) do
turns
|> Map.values
|> Enum.reduce(0, &(MapSet.size(&1) + &2))
>= :math.pow(Enum.max(@board_range), 2)
end
We count all the turns of both players. If this number is the same as the total amount of cells in our grid, then we know it is a draw.
We could do some pattern matching to unpack the x
and o
turns and do something like: MapSet.size(x_turns) + MapSet.size(y_turns) >= ...
, but hey, we as elixir fanboys re digging those pipelines, aren’t we?
To check if a player has won, we need to use the MapSet.subset?
function:
subset?(map_set1, map_set2)
Checks if map_set1’s members are all contained in map_set2.
MapSet.html#subset?/2
So imagine that we have a straight line: {0,0}, {1,0}, {2,0}
If we want to know if all points of this line exists in our set of turns:
iex(1)> hor_line = [{0, 0}, {1, 0}, {2, 0}]
iex(2)> player_turns = MapSet.new([{0, 0}, {1, 1}, {2, 0}, {1, 0}])
iex(3)> MapSet.subset?(MapSet.new(hor_line), player_turns)
true # So this player has won
Now we just need to generate all the possible lines. Then we can check if any of those lines are a subset of the player turns, and thus a winner:
posibilities |> Enum.any?(&MapSet.subset?(&1, player_turns))
Horizontal & Vertical lines
We use a nested for loop to generate 3 horizontal lines.
iex(1)> range = 0..2
iex(2)> for col <- range, do: for row <- range, do: {row, col}
[
[{0, 0}, {1, 0}, {2, 0}], # Top horizontal line
[{0, 1}, {1, 1}, {2, 1}], # Middle horizontal line
[{0, 2}, {1, 2}, {2, 2}] # Bottom horizontal line
]
For vertical lines, we simply switch the row and the column for each cell:
iex(3)> for col <- range, do: for row <- range, do: {col, row}
[
[{0, 0}, {0, 1}, {0, 2}], # Left vertical line
[{1, 0}, {1, 1}, {1, 2}], # Middle vertical line
[{2, 0}, {2, 1}, {2, 2}] # Right vertical line
]
Diagonal lines
We will only have 2 diagonal lines (no matter how big the grid). Let’s start with the one that goes from the top left corner to the bottom right corner:
iex(4)> for i <- range, do: {i, i}
[{0, 0}, {1, 1}, {2, 2}] # Top left -> Bottom right
For the other diagonal line (top right -> bottom left), we will do the same, only will the column count down, instead of counting up:
iex(5)> max = Enum.count(range)
iex(6)> for i <- range, do: {i, max - i - 1}
[{0, 2}, {1, 1}, {2, 0}] # Bottom left -> Top right
Tying it together
defp player_won?(player_turns) do
posibilities =
create_lines(@board_range, :horizontal) ++
create_lines(@board_range, :vertical) ++
create_lines(@board_range, :diagonal)
posibilities
|> Enum.map(&MapSet.new/1)
|> Enum.any?(&MapSet.subset?(&1, player_turns))
end
defp create_lines(range, :horizontal) do
for col <- range, do: for row <- range, do: {row, col}
end
defp create_lines(range, :vertical) do
for col <- range, do: for row <- range, do: {col, row}
end
defp create_lines(range, :diagonal) do
max = Enum.count(range)
[(for i <- range, do: {i, i})] ++
[(for i <- range, do: {i, max - i})]
end
We add all the lines together in one big list. After that, we need to convert this list to MapSet’s with Enum.map
, so that we can utilize the subset?
function that is explained earlier.
> mix test test/engine/game_test.exs
Finished in 0.06 seconds
8 tests, 1 failure
1) test players play a draw (EngineTest.GameTest)
test/engine/game_test.exs:55
Assertion with == failed
code: assert Game.status(game) == :playing
left: {:finished, :draw}
right: :playing
stacktrace:
test/engine/game_test.exs:70: (test)
Oh no! We have a test failing :/
It appears that draw?
is not working correctly:
defp draw?(turns) do
turns
|> Map.values
|> Enum.reduce(0, &(MapSet.size(&1) + &2))
>= :math.pow(Enum.max(@board_range), 2)
end
It has to do with the last line: :math:pow(Enum.max(@board_range), 2)
.
Our range is 0..2
, meaning [0, 1, 2]
. So the max of this range is 2
, but our intention was to calculate the amount of cells in our grid, which is 3x3=9.
Let’s fix this mistake:
:math.pow(Enum.count(@board_range), 2)
> mix test test/engine/game_test.exs
Finished in 0.06 seconds
8 tests, 0 failures
Hooray!
Final touch
The game is playable! Each player can take turns and we can check the status of the game. There are a few minor things that can still be improved though:
- Enforce correct player symbols
- Enforce players to take turns
Enforce correct player symbols
Make the following test work:
test "player can't take a turn with a wrong player symbol" do
game = Game.new
assert Game.take_turn(game, :y, {0, 0}) == {:error, :incorrect_player_symbol}
assert Game.take_turn(game, :wrongsymb, {0, 0}) == {:error, :incorrect_player_symbol}
end
Click here to see a possible solution
Using guards:
@player_symbols [:x, :o]
# ...
def take_turn(%Game{turns: turns} = game, player_symbol, cell)
when player_symbol in @player_symbols do
# ...
end
def take_turn(_game, _player_symbol, _cell) do
{:error, :incorrect_player_symbol}
end
Or adding to our existing cond
block:
def take_turn(%Game{turns: turns} = game, player_symbol, cell)
cond do
player_symbol not in @player_symbols ->
{:error, :incorrect_player_symbol}
cell_taken?(turns, cell) ->
{:error, :already_taken}
not within_bounds?(cell) ->
{:error, :out_of_bounds}
true ->
{:ok, update_in(game.turns[player_symbol], &MapSet.put(&1, cell))}
end
end
Enforce players to take turns
Make the following test work:
test "player can't take a turn twice in a row" do
game = Game.new
{:ok, game} = Game.take_turn(game, :x, {0, 0})
assert Game.take_turn(game, :x, {0, 1}) == {:error, :not_your_turn}
end
Click here to see a possible solution
@enforce_keys [:turns]
defstruct [:turns, :last_player]
def take_turn(%Game{turns: turns, last_player: last_player} = game, player_symbol, cell) do
cond do
player_symbol == last_player -> # Check the last player
{:error, :not_your_turn}
cell_taken?(turns, cell) ->
{:error, :already_taken}
not within_bounds?(cell) ->
{:error, :out_of_bounds}
true ->
game = update_in(game.turns[player_symbol], &MapSet.put(&1, cell))
{:ok, %{game | last_player: player_symbol}} # Set the last player
end
end