Building an event-sourced game with Phoenix Liveview: Building the view’s states from the events and reacting to changes
This article is part of the series Building an event-sourced game in Elixir with Phoenix Liveview. The full list of articles is:
- Building an event-sourced game with Phoenix Liveview: Introduction
- Building an event-sourced game with Phoenix Liveview: Architecture
- Building an event-sourced game with Phoenix Liveview: An event sourced model
- Building an event-sourced game with Phoenix Liveview: Handling errors
- Building an event-sourced game with Phoenix Liveview: Expressing domain concepts in the code
- Building an event-sourced game with Phoenix Liveview: Making game states explicit
- Building an event-sourced game with Phoenix Liveview: Game Server
- Building an event-sourced game with Phoenix Liveview: Acting on the game from the views
- Building an event-sourced game with Phoenix Liveview: Building the view’s states from the events and reacting to changes
- Building an event-sourced game with Phoenix Liveview: Decrementing the timer
- Building an event-sourced game with Phoenix Liveview: Unit testing patterns
In the last article, we’ve seen how the view communicates with the GameServer to send commands and act on the game. This article focuses on how the views construct their states.
The game being event-sourced its state is derived from the list of past events, and view states are not going to be different. In CQRS-ES language, they are called projections.
The first mode of building the view is rebuilding. When the view is loaded, we want it to represent the current state of the game. For example, when the game is waiting to start, some players might join while others are still not on the view. When a new player arrives, we want to display the list of all previously registered players.
The second mode is the running mode of building the view: when something occurs in the game, we want displayed information to change if necessary.
Rebuilding the view
First, let’s introduce the EventSourcedLiveView module that will encapsulate logic commons to all event-based views.
defmodule DoctorPWeb.EventSourcedLiveView do
@callback view_init( Phoenix.LiveView.unsigned_params() | :not_mounted_at_router,
session :: map(),
socket :: Phoenix.LiveView.Socket.t()) :: {integer(), map()}
defmacro __using__(opts \\ []) do
quote do
use DoctorPWeb, :live_view
@impl true
def mount(params, session, socket) do
{game_id, init_state} = view_init(params, session, socket)
socket = socket
|> assign(init_state)
{:ok, socket}
end
end
end
endThe module declares a macro that allows creating a specific type of LiveView, the event-sourced ones.
When the view is mounted, the init_view function is called and is expected to return a tuple containing the game ID and a map used as the initial state.
We can remove the mount function from the WaitingRoomLive we’ve seen in the previous article and instead write:
defmodule DoctorPWeb.WaitingRoomLive do
use DoctorPWeb.EventSourcedLiveView
def view_init(%{game_id: game_id}, session, socket) do
{
game_id,
%{
changeset: RegisterPlayer.changeset(%RegisterPlayer{}),
game_id: nil,
}
}
end
endTo rebuild the state from past events, we’ll modify the EventSourcedLiveView module.
The mount function fetches all known events from the GameServer and passes them to set_states, with the init_state we have from the previous call to init_view.
events = GameServer.get_events(game_id)
socket = socket
|> assign(set_state(init_state, events))set_state function goes through all events and looks for a clause of the apply_event matching each one of them. This is the same mechanism as for rebuilding the state we’ve seen when introducing event-sourced systems.
defp set_state(state, history), do:
Enum.reduce(history, state, fn e, state -> apply_event(e, state) end)With this in place, let’s see how we can display the teams as an example. We’ve seen before that the game dispatches a PlayerJoinedTeam event when a player registers, and we can use these events to achieve what we want.
First, we add an empty list as the list of teams in the initial state:
def view_init(%{game_id: game_id}, session, socket) do
{
game_id,
%{
changeset: RegisterPlayer.changeset(%RegisterPlayer{}),
game_id: nil,
teams: []
}
}
endThen, we add the apply_event function clause matching with the PlayerJoinedTeam event.
defp apply_event(%PlayerJoinedTeam{data: %{player: player, team: team_id}} = event, state) do
state
|> Map.put(:teams, add_player_to_teams(state.teams, player, team_id))
end
defp add_player_to_teams(teams, player, team_id) do
team = teams
|> Enum.at(team_id, [])
|> (fn t -> List.insert_at(t, length(t), player) end).()
if Enum.at(teams, team_id, nil) == nil do
List.insert_at(teams, team_id, team)
else
List.replace_at(teams, team_id, team)
end
endThis function, alongside the private add_player_to_teams function, adds the player to the indicated team in the list in the LiveView state.
Modifying the state while the game is running
We’ve seen just above how the view state is built from the already known events when the view is mounted, but we haven’t touched the funnier part yet, modifying the view when something occurs in the game. We’ll see this in that second section.
To communicate between the GameServer and the LiveViews, we’ll use the Phoenix PubSub system.
We introduce a GamePubSub module. Its role is to make it convenient to publish and subscribe to message for a specific game.
defmodule GamePubSub do
def subscribe(game_id), do:
Phoenix.PubSub.subscribe(DoctorP.PubSub, game_topic(game_id))
def publish(game_id, message), do:
Phoenix.PubSub.broadcast(DoctorP.PubSub, game_topic(game_id), message)
def game_topic(game_id), do: "game_#{game_id}"
endNext, the GameServer we’ve worked on in a previous article can be modified to dispatch events:
defmodule GameServer
#...
defp handle_command(command, state), do:
command
|> dispatch_command(state)
|> dispatch_events(state)
|> build_new_state(state)
end
defp dispatch_events(%ActionResult{events: events} = result, state) do
for event <- events, do: GamePubSub.publish(state.game_id, event)
result
endThe handle_command now pipes the result of dispatch_command into dispatch_events function.
dispatch_events calls the publish function of the newly created GamePubSub module for each event. The function returns its first parameters, the ActionResult coming from dispatch_command, which allows continuing to pipe into build_new_state.
Every time a command is dispatched and the game produces events, the GameServer broadcasts them via PubSub. That means we can subscribe and wait for events.
The EventSourcedLiveView module will precisely do this.
The mount function is changed to
def mount(params, session, socket) do
{game_id, initial_state} = view_init(params, session, socket)
if connected?(socket) do
GamePubSub.subscribe(game_id)
end
socket = socket
|> assign(set_state(initial_state, GameServer.get_events(game_id)))
{:ok, socket}
endWhen the view is connected, we subscribe for new events about the game.
For every message broadcasted by the PubSub system, the handle_info function is called and calls theapply_event function, hoping that a matching clause for the event exists. In that case, the state is modified and assigned to the socket.
@impl true
def handle_info(event, socket) do
state = apply_event(event, socket.assigns)
|> clean_assigns()
{:noreply, socket |> assign(state)}
end
defp clean_assigns(assigns) do
{_, clean_assigns} = Map.pop!(assigns, :flash)
clean_assigns
endA note here: I had to add the clean_assigns function to prevent LiveView assigns' error for the :flash reserved keys.
The apply_event function called in handle_info is the same one used when the view is mounted. It means the logic we created for building the list of teams is already working!
That’s it! We’ve seen everything needed for our views to display the correct information when they are mounted or when something occurs in the game.
In the next article, we’ll see the part I enjoyed the most coding in this project, as it forced me to rethink some pieces and improve the design, which is dealing with the timer.
- Improve your automated testing : You will learn how to fix your tests and make them pass from things that slow you down to things that save you time. This is a self-paced video course in French.
- Helping your teams: I help software teams deliver better software sooner. We'll work on technical issues with code, test or architecture, or the process and organization depending on your needs. Book a free call where we'll discuss how things are going on your side and how I can help you.
- Deliver a talk in your organization: I have a few talks that I enjoy presenting, and I can share with your organization(meetup, conference, company, BBL). If you feel that we could work on a new topic together, let's discuss that.
