Multiplayer Go with Elixir's Registry, PubSub and dynamic supervisors

Jeff Kreeftmeijer

Jeff Kreeftmeijer on

Multiplayer Go with Elixir's Registry, PubSub and dynamic supervisors

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.

1# lib/hayago/game.ex
2defmodule Hayago.Game do
3  # ...
4  use GenServer
6  def start_link(options) do
7    GenServer.start_link(__MODULE__, %Game{}, options)
8  end
10  @impl true
11  def init(game) do
12    {:ok, game}
13  end
15  @impl true
16  def handle_call(:game, _from, game) do
17    {:reply, game, game}
18  end
20  @impl true
21  def handle_cast({:place, position}, game) do
22    {:noreply, Game.place(game, position)}
23  end
25  @impl true
26  def handle_cast({:jump, destination}, game) do
27    {:noreply, Game.jump(game, destination)}
28  end
30  # ...

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.

1# lib/hayago/application.ex
2defmodule Hayago.Application do
3  # ...
5  def start(_type, _args) do
6    # List all child processes to be supervised
7    children = [
8      HayagoWeb.Endpoint,
9      {DynamicSupervisor, strategy: :one_for_one, name: Hayago.GameSupervisor}
10    ]
12    opts = [strategy: :one_for_one, name: Hayago.Supervisor]
13    Supervisor.start_link(children, opts)
14  end
16  # ...

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.

1% iex -S mix
2iex(1)> {:ok, pid} = DynamicSupervisor.start_child(Hayago.GameSupervisor, Hayago.Game)
3{:ok, #PID<0.286.0>}
4iex(2)> GenServer.cast(pid, {:place, 0})

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.

1iex(3)> Process.exit(pid, :kill)
3iex(4)> Process.alive?(pid)

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.

1iex(5)> {:ok, pid} = DynamicSupervisor.start_child(Hayago.GameSupervisor, {Hayago.Game, name: :game_1})
2{:ok, #PID<0.285.0>}
3iex(6)> Process.whereis(:game_1)
5iex(7)> Process.exit(pid, :kill)
7iex(8)> Process.whereis(:game_1)
9iex(9)> GenServer.cast(pid, {:place, 0})

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.

1# lib/hayago/application.ex
2defmodule Hayago.Application do
3  # ...
5  def start(_type, _args) do
6    children = [
7      HayagoWeb.Endpoint,
8      {Registry, keys: :unique, name: Hayago.GameRegistry},
9      {DynamicSupervisor, strategy: :one_for_one, name: Hayago.GameSupervisor}
10    ]
12    opts = [strategy: :one_for_one, name: Hayago.Supervisor]
13    Supervisor.start_link(children, opts)
14  end
16  # ...

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.

1iex(1)> {:ok, pid} = DynamicSupervisor.start_child(Hayago.GameSupervisor, {Hayago.Game, name: {:via, Registry, {Hayago.GameRegistry, "game_1"}}})
2{:ok, #PID<0.294.0>}
3iex(2)> Process.exit(pid, :kill)
4iex(3)> GenServer.cast({:via, Registry, {Hayago.GameRegistry, "game_1"}}, {:place, 0})

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.

1# lib/hayago_web/live/game_live.ex
2defmodule HayagoWeb.GameLive do
3  # ...
5  def mount(_session, socket) do
6    name =
7      ?a..?z
8      |> Enum.take_random(6)
9      |> List.to_string()
11    {:ok, _pid} =
12      DynamicSupervisor.start_child(Hayago.GameSupervisor, {Game, name: via_tuple(name)})
14    {:ok, assign_game(socket, name)}
15  end
17  # ...
19  defp via_tuple(name) do
20    {:via, Registry, {Hayago.GameRegistry, name}}
21  end
23  defp assign_game(socket, name) do
24    socket
25    |> assign(name: name)
26    |> assign_game()
27  end
29  defp assign_game(%{assigns: %{name: name}} = socket) do
30    game = GenServer.call(via_tuple(name), :game)
31    assign(socket, game: game, state: Game.state(game))
32  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.

1# lib/hayago_web/live/game_live.ex
2defmodule HayagoWeb.GameLive do
3  # ...
5  def handle_event("place", index, %{assigns: %{name: name}} = socket) do
6    :ok = GenServer.cast(via_tuple(name), {:place, String.to_integer(index)})
7    {:noreply, assign_game(socket)}
8  end
10  def handle_event("jump", destination, %{assigns: %{name: name}} = socket) do
11    :ok = GenServer.cast(via_tuple(name), {:jump, String.to_integer(destination)})
12    {:noreply, assign_game(socket)}
13  end
15  # ...

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.

1% 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.

1# lib/hayago_web/live/game_live.ex
2defmodule HayagoWeb.GameLive do
3  # ...
5  def handle_params(%{"name" => name} = _params, _uri, socket) do
6    {:noreply, assign_game(socket, name)}
7  end
9  def handle_params(_params, _uri, socket) do
10    name =
11      ?a..?z
12      |> Enum.take_random(6)
13      |> List.to_string()
15    {:ok, _pid} =
16      DynamicSupervisor.start_child(Hayago.GameSupervisor, {Game, name: via_tuple(name)})
18    {:ok,
19    live_redirect(
20      socket,
21      to: HayagoWeb.Router.Helpers.live_path(socket, HayagoWeb.GameLive, name: name)
22    )}
23  end
25  # ...

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.

1# lib/hayago_web/live/game_live.ex
2defmodule HayagoWeb.GameLive do
3  # ...
5  def handle_params(%{"name" => name} = _params, _uri, socket) do
6    :ok = Phoenix.PubSub.subscribe(Hayago.PubSub, name)
7    {:noreply, assign_game(socket, name)}
8  end
10  # ...
12  def handle_event("place", index, %{assigns: %{name: name}} = socket) do
13    :ok = GenServer.cast(via_tuple(name), {:place, String.to_integer(index)})
14    :ok = Phoenix.PubSub.broadcast(Hayago.PubSub, name, :update)
15    {:noreply, assign_game(socket)}
16  end
18  def handle_event("jump", destination, %{assigns: %{name: name}} = socket) do
19    :ok = GenServer.cast(via_tuple(name), {:jump, String.to_integer(destination)})
20    :ok = Phoenix.PubSub.broadcast(Hayago.PubSub, name, :update)
21    {:noreply, assign_game(socket)}
22  end
24  def handle_info(:update, socket) do
25    {:noreply, assign_game(socket)}
26  end
28  # ...

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.

1# lib/hayago/game.ex
2defmodule Hayago.Game do
3  # ...
4  use GenServer, restart: :transient
6  @timeout 600_000
8  def start_link(options) do
9    GenServer.start_link(__MODULE__, %Game{}, options)
10  end
12  @impl true
13  def init(game) do
14    {:ok, game, @timeout}
15  end
17  @impl true
18  def handle_call(:game, _from, game) do
19    {:reply, game, game, @timeout}
20  end
22  @impl true
23  def handle_cast({:place, position}, game) do
24    {:noreply, Game.place(game, position), @timeout}
25  end
27  @impl true
28  def handle_cast({:jump, destination}, game) do
29    {:noreply, Game.jump(game, destination), @timeout}
30  end
32  @impl true
33  def handle_info(:timeout, game) do
34    {:stop, :normal, game}
35  end
37  # ...

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!

We're hiring: ✍️ (Remote) Editor in Chief @AppSignal ✏️

Share this article


AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps