Elixir Agent: Encapsulating the gen_server state logic
Elixir introduces Agent
, a pre-built abstraction of gen_server
that simplifies state management.
Tic-Tac-Toe
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 tonil
, indicating an empty space ready for player moves. - Win Condition: Sets the
win
field tonil
, 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
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
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.