Building an event-sourced game with Phoenix Liveview: Acting on the game from the views
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 the GameServer implementation. It’s now time to look at the views, specifically how players interact with the game.
Starting the game from the LiveView
I decided to use LiveViews as they are convenient for real-time interactions without requiring any Javascript code.
In this article, we’ll focus on the LiveView used for players to register as it exhibits handling form, sending messages to the GameServer
, and handling potential errors.
Let’s first create a new module for our LiveView. When the view is mounted, the game_id
available in the route parameters is stored in the state.
defmodule DoctorPWeb.WaitingRoomLive do
def mount(%{game_id: game_id}, _session, socket) do
state = %{game_id: game_id}
socket = socket
|> assign(state)
{:ok, socket}
end
end
The simplest case to handle in this state is handling the click on the "Start game" button. The LiveView receives an event without any parameters and sends a message to the GameServer
.
@impl true
def handle_event("start_playing", _params, socket) do
GameServer.start_playing(socket.assigns.game_id)
{:noreply, socket}
end
Thanks to the client function we’ve added to GameServer
and being able to find it using the game ID, the implementation is easy. When a start_playing
event is received by the LiveView process a call is issued to GameServer.start_playing(socket.assigns.game_id)
.
Registering a player from the LiveView
Registering a player is more complex because it involves a form for the player to specify her name.
Following ideas from this article, I managed to use Ecto as a validation library to create a form that is not tied to a database.
We declare a module describing the form:
defmodule RegisterPlayer do
use Ecto.Schema
import Ecto.Changeset
embedded_schema do
field(:player_name, :string)
end
alias __MODULE__
def changeset(register_player, params \\ %{}) do
register_player
|> cast(params, [:player_name])
|> validate_required([:player_name])
|> validate_length(:player_name, min: 1)
end
def apply_changes(changeset) do
Ecto.Changeset.apply_changes(changeset)
end
end
Once this module is created, we can use the form in the LiveView.
In the mount
function we need to add the form changeset.
state = %{game_id: game_id, changeset: RegisterPlayer.changeset(%RegisterPlayer{})}
And modify the template to display the form:
<%= form_for @changeset, "#", [phx_submit: :register_player, id: "player_registration_form"], fn f -> %>
<%= label f, :player_name %>
<%= text_input f, :player_name, autofocus: true, placeholder: "What’s your name?" %>
<%= error_tag f, :player_name %>
<%= submit "Register", phx_disable_with: "Registering..." %>
<% end %>
</form>
With this in place, the next step is to handle the form by creating a clause for handle_event
matching the register_player
event.
def handle_event("register_player", %{"register_player" => register_player} = params, socket) do
changeset =
%RegisterPlayer{}
|> RegisterPlayer.changeset(register_player)
|> Map.put(:action, :insert)
with true <- changeset.valid?,
registration <- RegisterPlayer.apply_changes(changeset),
:ok <- GameServer.add_player(socket.assigns.game_id, registration.player_name) do
{:noreply, socket}
else
_ -> {:noreply, assign(socket, :changeset, changeset)}
end
end
In the first part of the function, a new changeset for the form is created based on the form’s data.
If the changeset is valid, the changes are applied to an empty RegisterUser
structure. We access data from that structure to call GameServer.add_player
.
If anything is not going as expected, we enter the else
part of the with
structure. The changeset is given back to the view, allowing to display form validation errors to the player.
Displaying errors from the GameServer
Another type of error is the ones returned by the GameServer when it cannot handle a command.
Let’s take an example with the registration process. As seen in the article about errors, we disallow two players to share the same name. If a player tries to register with a name taken by another player, an :player_name_not_available
is returned by GameServer.add_player
.
Sure, we could add some logic in the WaitingRoomLive
LiveView module to keep track of all registered players and prevent form submission if needed, but what if two players try to register with the same still available name at the exact same moment?
The GameServer
, as the source of truth, will handle one command after the other and refuse the second one.
Let’s see how to handle this.
First, we add the add_player_name_already_taken_error
to the RegisterPlayer
module, the one responsible for the registration form.
defmodule RegisterPlayer do
# ....
@name_already_taken_error_message "This name is already taken by another player"
def add_player_name_already_taken_error(changeset), do:
changeset
|> add_error(:player_name, @name_already_taken_error_message)
end
This function adds an error for the :player_name
field to the changeset.
Then, we can modify the with
structure we’ve seen previously to use that function when needed.
with true <- changeset.valid?,
registration <- RegisterPlayer.apply_changes(changeset),
:ok <- GameServer.add_player(socket.assigns.game_id, socket.assigns.player_id, registration.player_name) do
{:noreply, socket}
else
{:error, :player_name_not_available} -> {:noreply, assign(socket, :changeset, RegisterPlayer.add_player_name_already_taken_error(changeset))}
_ -> {:noreply, assign(socket, :changeset, changeset)}
end
If GameServer.add_player
returns the {:error, :player_name_not_available}
error tuple, we assign a modified changeset with the error to the socket.
That’s it for this article. We’ve covered all needed for players to act against the game. In the next article, we’ll see how the view constructs its state from the game’s state.