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

  1. Creating the game engine [Part 1]
  2. Wrap it up in a server (GenServer & Supervision) [Part 2]
  3. Building a web interface with Phoenix [Part 3]
  4. Setting up Vue.js with Phoenix [Part 4]
  5. Creating the Vue.js frontend [Part 5]
  6. Real-time Tic-Tac-Toe with Phoenix Channels [Part 6]

Table of contents

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 traverse index 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 :

/lib/engine/game.ex
defmodule Game do
  
  @enforce_keys :turns
  defstruct :turns

  def new do
    %Game{turns: %{x: MapSet.new, o: MapSet.new}}
  end
end

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.

/lib/engine/game.ex
defmodule Game do
  
  @enforce_keys :turns
  defstruct :turns

  def new do
    %Game{turns: %{x: MapSet.new, o: MapSet.new}}
  end

  def take_turn(game, player_symbol, cell) do # ... end

  def status(game) do #... end
end

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 state
  • player_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 ;)

/test/engine/game_test.ex
defmodule EngineTest.GameTest do
  use ExUnit.Case
  alias Engine.Game

  doctest Game

  test "player can take a turn" do
    game = Game.new
    assert {:ok, _game} = Game.take_turn(game, :x, {0, 0})
  end

  test "player can't make take a turn on a non-free cell" do
    game = Game.new
    {:ok, game} = Game.take_turn(game, :x, {0, 0})

    # Player already owns the cell
    assert Game.take_turn(game, :o, {0, 0}) == {:error, :already_taken}
    {:ok, game} = Game.take_turn(game, :o, {0, 1})

    # Other player already owns the cell
    assert Game.take_turn(game, :x, {0, 1}) == {:error, :already_taken}    
  end

  test "player can't take a turn on a cell outside the grid" do
    game = Game.new
    assert Game.take_turn(game, :o, {5, 8}) == {:error, :out_of_bounds}  
    assert Game.take_turn(game, :o, {-1, 2}) == {:error, :out_of_bounds}  
    assert Game.take_turn(game, :o, {0, 4}) == {:error, :out_of_bounds} 
  end
end

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