Welcome back to Elixir Alchemy, and to the third part in our series on implementing the Go game using Phoenix LiveView. In part one, we set up the game using LiveView and in part two, we added Game history and implemented the ko rule.
Currently, our game is a local "hot seat" multiplayer game where players take turns on the same browser window. Our next adventure aims to turn the game into a true multiplayer experience by allowing players to play with others online.
In this article, we'll take the first step towards that goal. We'll allow the creation of new games as well as inviting others to join in by turning the Game struct into a dynamically supervised GenServer, we'll keep track of games using Elixir's Registry, we'll allow players to connect to already started games, and we'll broadcast moves to all connected players using Phoenix.PubSub
.
Where We Left Off
The starter app for this episode is where we left off last time. We have an implementation of the game with buttons to undo and redo moves.
The final result can be played online, and the code for the completed application can be found in the repository's master branch (if you prefer to jump straight into the code).
We have a lot to do, so let's get started!
Dynamically Supervised GenServers
Currently, the game's state is kept in the player's socket connection. This works for games with a single player, but there's no way for another socket connection to access an existing game. To allow two players to play the game together over two socket connections, we need to store each started game in a separate process that both can access.
We'll use a dynamically supervised GenServer to keep each game's state. The dynamic supervisor allows players to start games and it also automatically restarts them when a game process crashes.
First, let's turn our Game module into a GenServer to allow multiple processes to access it.
# lib/hayago/game.ex
defmodule Hayago.Game do
# ...
use GenServer
def start_link(options) do
GenServer.start_link(__MODULE__, %Game{}, options)
end
@impl true
def init(game) do
{:ok, game}
end
@impl true
def handle_call(:game, _from, game) do
{:reply, game, game}
end
@impl true
def handle_cast({:place, position}, game) do
{:noreply, Game.place(game, position)}
end
@impl true
def handle_cast({:jump, destination}, game) do
{:noreply, Game.jump(game, destination)}
end
# ...
end
Our GenServer handles the :game
call, which returns the current game's Game
struct. The {:place, position}
and {:jump, destination}
cast callbacks update the game by calling Game.place/2
and Game.jump/2
, respectively.
With our GenServer callbacks in place, we can spawn a process that keeps a game's state. To supervise these processes, we'll use Elixir's DynamicSupervisor
, which allows spawning children on demand. We'll use it to start a new game when a player opens the page.
# lib/hayago/application.ex
defmodule Hayago.Application do
# ...
def start(_type, _args) do
# List all child processes to be supervised
children = [
HayagoWeb.Endpoint,
{DynamicSupervisor, strategy: :one_for_one, name: Hayago.GameSupervisor}
]
opts = [strategy: :one_for_one, name: Hayago.Supervisor]
Supervisor.start_link(children, opts)
end
# ...
end
We'll set up our dynamic supervisor in the app's Application
module. The :one_for_one
strategy (which is the only one available for dynamic supervisors) ensures that a game is restarted whenever it crashes.
Pids, Atoms and the Registry
Because our Game module now implements GenServer callbacks, we can spawn a Game through the GenServer module.
% iex -S mix
iex(1)> {:ok, pid} = DynamicSupervisor.start_child(Hayago.GameSupervisor, Hayago.Game)
{:ok, #PID<0.286.0>}
iex(2)> GenServer.cast(pid, {:place, 0})
:ok
The returned pid is used to get and update the game's state. In this case, we use it to place a stone on the top-left position of the board.
Instead of keeping the game in the socket for every connection, we could add the pid to the socket and then request and update its state through that.
However, our supervisor is tasked with restarting any game process that crashes. When that happens, the game is restarted in a new process with a new pid. When that happens, the players will still be disconnected from the game because the only reference they have to the game will no longer be working, and they'll have no way of getting the new pid.
iex(3)> Process.exit(pid, :kill)
true
iex(4)> Process.alive?(pid)
false
A solution to this is naming processes when they're spawned so that we can refer to them by name. Whenever a named process is restarted by a supervisor, the newly started process automatically receives the name of the process it replaces.
iex(5)> {:ok, pid} = DynamicSupervisor.start_child(Hayago.GameSupervisor, {Hayago.Game, name: :game_1})
{:ok, #PID<0.285.0>}
iex(6)> Process.whereis(:game_1)
#PID<0.285.0>
iex(7)> Process.exit(pid, :kill)
true
iex(8)> Process.whereis(:game_1)
#PID<0.288.0>
iex(9)> GenServer.cast(pid, {:place, 0})
:ok
Here, we start a new game and use :game_1
as its name. If we kill the game process, another one is automatically spawned to replace it. The new process shares the same name, so we can continue using :game_1
to refer to the new process.
This way, we could generate a unique name for each process when spawning it, and keep that in our socket to refer to the game later. But, wait! We're naming our processes with atoms here. Since atoms aren't garbage collected, spawning a lot of games can exhaust our memory. Instead, we'd like to use strings, which are garbage collected.
Because we can't name a process with a string, we need to use a Registry to link string names to game pids. Elixir's GenServer implementation has a built-in way of referring to processes in a registry through it's :via
-tuples. First, let's start the registry in our main supervisor whenever the application starts.
# lib/hayago/application.ex
defmodule Hayago.Application do
# ...
def start(_type, _args) do
children = [
HayagoWeb.Endpoint,
{Registry, keys: :unique, name: Hayago.GameRegistry},
{DynamicSupervisor, strategy: :one_for_one, name: Hayago.GameSupervisor}
]
opts = [strategy: :one_for_one, name: Hayago.Supervisor]
Supervisor.start_link(children, opts)
end
# ...
end
With the new registry in place, we can start processes using a string as their name, and refer to them later without creating an atom for every spawned game.
iex(1)> {:ok, pid} = DynamicSupervisor.start_child(Hayago.GameSupervisor, {Hayago.Game, name: {:via, Registry, {Hayago.GameRegistry, "game_1"}}})
{:ok, #PID<0.294.0>}
iex(2)> Process.exit(pid, :kill)
iex(3)> GenServer.cast({:via, Registry, {Hayago.GameRegistry, "game_1"}}, {:place, 0})
:ok
Switching to Game Processes
Instead of creating a new game struct and assigning it directly to the socket, we'll update the GameLive.mount/2
function to start a supervised process that holds a game's state.
# lib/hayago_web/live/game_live.ex
defmodule HayagoWeb.GameLive do
# ...
def mount(_session, socket) do
name =
?a..?z
|> Enum.take_random(6)
|> List.to_string()
{:ok, _pid} =
DynamicSupervisor.start_child(Hayago.GameSupervisor, {Game, name: via_tuple(name)})
{:ok, assign_game(socket, name)}
end
# ...
defp via_tuple(name) do
{:via, Registry, {Hayago.GameRegistry, name}}
end
defp assign_game(socket, name) do
socket
|> assign(name: name)
|> assign_game()
end
defp assign_game(%{assigns: %{name: name}} = socket) do
game = GenServer.call(via_tuple(name), :game)
assign(socket, game: game, state: Game.state(game))
end
end
We create a random six-character string as the name of our process, which we use when spawning the process through our GameSupervisor
. The via_tuple/1
convenience function returns the :via
-tuple that we'll need to register the process' name in the registry.
Finally, we add an assign_game/1-2
function that takes a socket of the game's registered name. It calls out to the GenServer process to get the game's current state before assigning it to the socket.
Next, we'll switch both handle_event/3
variants to use the game via the GenServer.
# lib/hayago_web/live/game_live.ex
defmodule HayagoWeb.GameLive do
# ...
def handle_event("place", index, %{assigns: %{name: name}} = socket) do
:ok = GenServer.cast(via_tuple(name), {:place, String.to_integer(index)})
{:noreply, assign_game(socket)}
end
def handle_event("jump", destination, %{assigns: %{name: name}} = socket) do
:ok = GenServer.cast(via_tuple(name), {:jump, String.to_integer(destination)})
{:noreply, assign_game(socket)}
end
# ...
end
Again, we use the via_tuple/1
function to cast both our :place
and :jump
functions directly on the game's process. We then reassign the game to the socket by calling our assign_game/1
function.
Multiplayer URL Sharing
To allow multiple players to connect to the same game, we'll add the game's name to a URL that the first user can share. When a user starts a new game, we'll use the pushState
function from the HTML5 history API to add the game's name to the URL without reloading the page.
We'll use Phoenix LiveView's live_redirect/2
function for that—which was added recently after we started working on the game. To make sure we're on a recent enough version, let's update the dependency before continuing.
% mix deps.update phoenix_live_view
When a player visits the game without a game name in the URL, the app will start a new one and update the URL to include the newly created game's name. In our case, the game is served on the root of our application, so visiting http://localhost:4000 will redirect to http://localhost:4000?name=abcdef, where "abcdef" is the game's name.
To do this, we'll replace our mount/2
function with two variants of the handle_params/3
function.
# lib/hayago_web/live/game_live.ex
defmodule HayagoWeb.GameLive do
# ...
def handle_params(%{"name" => name} = _params, _uri, socket) do
{:noreply, assign_game(socket, name)}
end
def handle_params(_params, _uri, socket) do
name =
?a..?z
|> Enum.take_random(6)
|> List.to_string()
{:ok, _pid} =
DynamicSupervisor.start_child(Hayago.GameSupervisor, {Game, name: via_tuple(name)})
{:ok,
live_redirect(
socket,
to: HayagoWeb.Router.Helpers.live_path(socket, HayagoWeb.GameLive, name: name)
)}
end
# ...
end
The first variant handles requests that have a name in the URL parameters. In that case, the latest game state is fetched from the process corresponding to the name from the parameters and assigned to the socket using the assign_game/2
function.
The second is a lot like the mount/2
function that we're replacing. We're generating a name and starting a game. However, instead of assigning the game to the socket and returning, this function uses LiveView's live_redirect/2
function to add the name to the current URL. After the URL changes, the first variant is automatically executed, assigning the name and game state to the socket.
Broadcasting Moves to All Clients
If we open our game right now, we'll see that every visit to http://localhost:4000 gets redirected to a URL with a name query parameter. Opening that URL twice connects two sockets to the same game.
However, when a stone is placed in one of the windows, it doesn't automatically appear in the other one. Only after refreshing the window do we see the correct placement of stones.
Everything is wired up correctly, but the second socket isn't getting notified when the first makes a move. We need a way to get all connected sockets to subscribe to a system that publishes updates to all clients when one makes a move.
We'll use Phoenix.PubSub
to make that happen. Back in our GameLive
module, we'll subscribe each socket connection to a channel when a game is created, and then we'll broadcast a message over that channel whenever we place a stone or travel in history.
# lib/hayago_web/live/game_live.ex
defmodule HayagoWeb.GameLive do
# ...
def handle_params(%{"name" => name} = _params, _uri, socket) do
:ok = Phoenix.PubSub.subscribe(Hayago.PubSub, name)
{:noreply, assign_game(socket, name)}
end
# ...
def handle_event("place", index, %{assigns: %{name: name}} = socket) do
:ok = GenServer.cast(via_tuple(name), {:place, String.to_integer(index)})
:ok = Phoenix.PubSub.broadcast(Hayago.PubSub, name, :update)
{:noreply, assign_game(socket)}
end
def handle_event("jump", destination, %{assigns: %{name: name}} = socket) do
:ok = GenServer.cast(via_tuple(name), {:jump, String.to_integer(destination)})
:ok = Phoenix.PubSub.broadcast(Hayago.PubSub, name, :update)
{:noreply, assign_game(socket)}
end
def handle_info(:update, socket) do
{:noreply, assign_game(socket)}
end
# ...
end
In handle_params/3
(the variant that matches on URLs with name query parameters), we call out to Phoenix.PubSub.subscribe/2
. We use the game's name as the channel's topic. Because all connected clients will hit this function, we know they'll all be subscribed to this topic.
In both handle_event/3
functions, we use Phoenix.PubSub.broadcast/3
to send a message to all subscribers in the topic. We send :update
as the message, which is then picked up in a newly added handle_info/2
function, which fetches the current Game's state and updates the view whenever it receives an update message.
To make sure games are cleaned up, we need to terminate their processes when they're no longer being used. For now, we'll add a timeout to the GenServer's callback functions. When a game hasn't had any interaction for ten minutes, the process will send itself a :timeout
message to stop the process.
# lib/hayago/game.ex
defmodule Hayago.Game do
# ...
use GenServer, restart: :transient
@timeout 600_000
def start_link(options) do
GenServer.start_link(__MODULE__, %Game{}, options)
end
@impl true
def init(game) do
{:ok, game, @timeout}
end
@impl true
def handle_call(:game, _from, game) do
{:reply, game, game, @timeout}
end
@impl true
def handle_cast({:place, position}, game) do
{:noreply, Game.place(game, position), @timeout}
end
@impl true
def handle_cast({:jump, destination}, game) do
{:noreply, Game.jump(game, destination), @timeout}
end
@impl true
def handle_info(:timeout, game) do
{:stop, :normal, game}
end
# ...
end
By adding a timeout in milliseconds to every callback's response tuple, we tell the GenServer to send itself a timeout message when the process hasn't received a message for ten minutes. The :timeout
callback returns a :stop
-tuple to tell the process to stop.
We also make sure to set the :restart
value to :transient
when including the GenServer code to ensure that the supervisor only restarts the game when it terminates abnormally.
What's Next?
This concludes the third part on implementing the Go game in Phoenix. We've come quite a long way in implementing a true multiplayer game, and we've learned about Elixir's dynamic supervisors, the Registry, and Phoenix.PubSub
along the way.
In a future episode, we'll continue turning our game into a multiplayer game by assigning each connection as a separate player by assigning each of them a color of stones on the board. See you then!
Top comments (0)