Elixir introduces Agent, a pre-built abstraction of gen_server that simplifies state management.

Tic-Tac-Toe

See the code

Read the docs

The GameApp.Games.TicTacToe module leverages Elixir’s Agent to manage the state of Tic-Tac-Toe.

defmodule GameApp.Games.TicTacToe.WithAgent do
  use Agent
  require Logger
  alias GameApp.Games.TicTacToe.GameLogic

  @tic_tac_toe_topic "tic_tac_toe"
  @valid_positions [:a1, :a2, :a3, :b1, :b2, :b3, :c1, :c2, :c3]

  def initial_state do
    %GameLogic{} |> Map.put(:topic, @tic_tac_toe_topic)
  end

  def start_link(opts \\ []) do
    with {:ok, pid} <- Agent.start_link(fn -> initial_state() end, opts) do
      broadcast_update(:update, initial_state())
      {:ok, pid}
    end
  end

end
@valid_positions

Defines a list of all valid positions on a standard 3x3 Tic-Tac-Toe board.

GameLogic{}

Utilizes Elixir’s defstruct macro to define the identity of the game state:

  • Board Initialization: Initializes the board with each position set to nil, indicating an empty space ready for player moves.
  • Win Condition: Sets the win field to nil, which means there is no winning pattern.
  • Game Status: Establishes the game’s status as :ongoing, signaling active gameplay.
initial_state/0

This function returns the initialized struct of the module, providing a convenient way to access the identity game state.

start_link/1

A gen_server requires a start behavior. In this case, TicTacToe’s start_link function delegates to the Agent’s start_link, which initializes the Agent with the initial state, broadcasts its current state, and links this Agent to the process that started it. A supervisor looks for a function named start_link to manage process startups.

Gameplay

  def mark(position) when position in @valid_positions do
    Agent.update(__MODULE__, fn state ->
      case Map.get(state.board, position) do
        nil ->
          new_board = Map.put(state.board, position, state.current_player)
          {new_player, new_winner, new_game_status} = update_game(state, new_board)

          new_state = %{
            state
            | board: new_board,
              current_player: new_player,
              win: new_winner,
              game_status: new_game_status
          }

          broadcast_update(:update, new_state)
          new_state

        _ ->
          state
      end
    end)
  end

  def mark(_position), do: {:error, :invalid_position}

When mark is called with a valid position, the Agent checks if the position is occupied. If so, the state is saved unchanged, if the position is unoccupied, the Agent saves the updated state.

Concurrency

Because this TicTacToe game can be played by any number of clients at the same time, it is a concurrency problem. The Agent, using the underlying gen_server state management behavior to handle the order of operations, ensures that actions are processed sequentially and atomically. When multiple calls to mark(:a1) are made, only the first valid call that finds :a1 unoccupied will succeed in marking the position, implementing a “first in wins” strategy. This idempotent approach ensures consistency without the need for a locking mechanism.

Updates

In the TicTacToe game, changes to the game state are not returned directly from the mark function. Instead, updates are broadcasted, ensuring interested clients are informed of state changes in real-time.

defp broadcast_update(action, state) do
    case Phoenix.PubSub.broadcast(GameApp.PubSub, @tic_tac_toe_topic, {action, state}) do
      :ok ->
        :ok

      error ->
        Logger.error("Failed to broadcast game update

: #{inspect(error)}")
        :error
    end
  end

Missed Messages

Each message broadcasted in the TicTacToe game carries the full state, helping to mitigate issues of missed messages. Also, in the instance where a client needs to hydrate or refresh the current state, it can call for the agent’s current state via the get_state function:

def get_state do
  Agent.get(__MODULE__, & &1)
end

Using a Supervisor

Although any process can manage a child process’s lifecycle, typically applications use a supervisor, which is a process that specializes in managing the lifecycle of gen_servers.

GameApp has a top-level supervisor in application.ex.

def start(_type, _args) do
  children = [
    {TicTacToe, name: TicTacToe}
  ]

  opts = [strategy: :one_for_one, name: GameApp.Supervisor]

  Supervisor.start_link(children, opts)
end

On application start, the top-level supervisor adds TicTacToe’s Agent. When the Agent crashes, the supervisor will automatically restart it.

Supervision Strategy

This uses the :one_for_one strategy, so when the TicTacToe Agent process crashes, it will be restarted automatically into the known good state defined in the module’s defstruct.

  • State Loss: This setup prioritizes resilience and robustness over state persistence, useful when availability is more important than preserving transient state.

  • Singleton Agent: The TicTacToe Agent will run once per application instance, so client sessions will interact with the same game state.

Client Interaction

See the code

The client needs to manage game state hydration, user interactions, and nees to update its state based on messages from the pubsub.

defmodule GameAppWeb.TicTacToeLive.Index do
  use GameAppWeb, :live_view
  alias GameApp.Games.TicTacToe

  def mount(_params, _session, socket) do
    Phoenix.PubSub.subscribe(GameApp.PubSub, TicTacToe.topic())
    state = TicTacToe.get_state()
    {:ok, assign(socket, game_state: state)}
  end

  def handle_event("mark", %{"position" => position}, socket) do
    TicTacToe.mark(String.to_atom(position))
    {:noreply, socket}
  end

  def handle_info({:update, new_state}, socket) do
    {:noreply, assign(socket, game_state: new_state)}
  end
end
  • mount: Upon mounting, the client component subscribes to updates from GameApp.PubSub and hydrates its initial state by retrieving the current game state from TicTacToe’s Agent.

  • handle_event: This handles the player’s selection of a position on the board. It sends this input to the TicTacToe module, which updates the game state and broadcasts the change to all clients.

  • handle_info: Captures and processes {:update, new_state} messages from the PubSub. It updates the client’s state to reflect the current game state.

Crashing

We use GenServers and supervisors for their resilience. Here is an example of intentionally crashing an Agent holding the TicTacToe state:

def crash_server do
  Agent.update(__MODULE__, fn _state ->
    raise "BOMB!!!"
  end)
end

This raises an exception and crashes the Agent. The supervisor monitoring the Agent will detect the crash and automatically restart the Agent.

The LiveView process that calls this function is tightly coupled to the Agent, meaning it will also crash. When the LiveView process restarts, it will rehydrate its state from the restarted Agent and return to a consistent state.

Other LiveView processes that are also interacting with the same game will not be immediately aware of the crash. However, because the Agent broadcasts an update message upon restart, these LiveViews will receive the message and update their state accordingly. This ensures that all LiveViews return to a consistent state and remain synchronized with the Agent’s state.

Testing

See the code

defmodule GameApp.Games.TicTacToeTest do
  use ExUnit.Case, async: false
  alias GameApp.Games.TicTacToe

  setup do
    {:ok, _pid} = TicTacToe.start_link()
    TicTacToe.reset()
    :ok
  end

  describe "mark/1" do
    test "should mark a valid position" do
      :ok = TicTacToe.mark(:a1)
      state = TicTacToe.get_state()
      assert state.board[:a1] == :x
      assert state.current_player == :o
      assert state.win == nil
      assert state.game_status == :ongoing
    end

    test "should not mark an invalid position" do
      assert {:error, :invalid_position} = TicTacToe.mark(:z1)
    end

    test "should not change an occupied position" do
      :ok = TicTacToe.mark(:a1)
      :ok = TicTacToe.mark(:a1)
      state = TicTacToe.get_state()
      assert state.board[:a1] == :x
      assert state.current_player == :o
    end

    test "should detect a win condition" do
      state = TicTacToe.get_state()
      assert state.win == nil
      assert state.game_status == :ongoing

      :ok = TicTacToe.mark(:a1)
      :ok = TicTacToe.mark(:b1)
      :ok = TicTacToe.mark(:a2)
      :ok = TicTacToe.mark(:b2)
      :ok = TicTacToe.mark(:a3)

      state = TicTacToe.get_state()
      assert state.win == [:a1, :a2, :a3]
      assert state.game_status == :win
    end

    test "should detect a tie" do
      state = TicTacToe.get_state()
      assert state.game_status == :ongoing

      :ok = TicTacToe.mark(:a1)
      :ok = TicTacToe.mark(:a2)
      :ok = TicTacToe.mark(:a3)
      :ok = TicTacToe.mark(:b1)
      :ok = TicTacToe.mark(:b3)
      :ok = TicTacToe.mark(:b2)
      :ok = TicTacToe.mark(:c1)
      :ok = TicTacToe.mark(:c3)
      :ok = TicTacToe.mark(:c2)

      state = TicTacToe.get_state()
      assert state.game_status == :tie
      assert state.win == nil
    end
  end

  describe "reset/0" do
    test "resets the game to initial conditions" do
      TicTacToe.reset()
      state = TicTacToe.get_state()
      initial_state = TicTacToe.initial_state()
      assert state == initial_state
    end
  end
end
  • Setup: Before each test, the TicTacToe agent is started and reset to ensure a clean state, providing a consistent baseline for all tests.
  • Marking a Position: Confirms that marking a valid position updates the game state correctly, changing the current player and maintaining the game’s ongoing status.
  • Invalid Position: Validates that attempting to mark an invalid position (e.g., :z1) appropriately returns an error, indicating robust input validation.
  • Occupied Position: Ensures that trying to mark an already occupied position does not change the state, preserving game integrity.
  • Win Condition: Verifies that the game correctly identifies a win condition when one player aligns three marks in a row, column, or diagonal.
  • Tie Condition: Tests that the game accurately detects a tie condition when all positions are filled without any player winning.
  • Reset: Ensures the reset function effectively restores the game to its initial conditions, allowing for a fresh start.

Given the shared nature of the TicTacToe agent used to manage state, tests are run with async: false. This setting prevents tests from running concurrently, which is crucial since simultaneous tests could interact destructively with the same state. This approach avoids interference, ensuring that each test’s actions and outcomes remain isolated and predictable.