Secondary Process Registry

- 5 mins read

Get game state by join_code

I need to update how the game LiveView fetches the initial game state when the LV mounts so it uses a join_code instead of a game_id. I first update the router path slugs to use /:join_code instead of /:game_id and update the mount function to match this change. To preserve the existing behavior, I map join_code to game_id which is used elsewhere in the mount function.

def mount(%{"join_code" => join_code}, _, socket) do
  game_id = join_code

  # ...
end

game_id will still be passed to the GameEngine module which will map to the game session processes so I will need to extract the id from the game state and assign to the socket during mount. To get the game state by join_code, I will need to create a new function which will be used to replace the call to GameEngine.get_game(game_id).

I start by creating a new test in GameEngineTest module which will be used to design and validate the behavior of this new function.

defmodule Minotaur.GameEngineTest do
  # ...

  describe "get_game_by_join_code/1" do
    setup [:create_game, :start_game_session]

    test "should return game state for matching code", ctx do
      assert {:ok, game} = GameEngine.get_game_by_join_code(ctx.join_code)
      assert ctx.game.id == game.id
    end
  end
end

I then iterate through running the test and resolving any errors until the test compiles. I implement the setup helpers and add an empty get_game_by_join_code function.

defmodule Minotaur.GameEngineTest do
  # …

  defp create_game(_) do
    [join_code: "AABB", game: game_fixture()]
  end

  defp start_game_session(ctx) do
    {:ok, _pid} = GameEngine.continue_game(ctx.join_code, ctx.game)
    :ok
  end
end
defmodule Minotaur.GameEngineTest do
  # …

  def get_game_by_join_code(_join_code) do
  end
end

Once the test compiles, I continue iterating until the test is passing. I follow the pattern of the body of get_game which looks up the process by game_id and update get_game_by_join_code to instead get the game session from a different registry by join code.

  def get_game(game_id) do
    game = GenServer.call(via_tuple(game_id), :get_game)
    {:ok, game}
  catch
    :exit, {:noproc, _} -> {:error, :game_not_alive}
  end

  def get_game_by_join_code(join_code) do
    via_tuple = {:via, Horde.Registry, {SessionJoinCodeRegistry, join_code}}
    game = GenServer.call(via_tuple, :get_game)

    {:ok, game}
  catch
    :exit, {:noproc, _} -> {:error, :game_not_alive}
  end

SessionJoinCodeRegistry doesn’t exist so I define it as a child Registry process of the Minotaur.Application module next. I then update GameEngine.SessionServer to register new game processes with this new registry during the init callback.

  def init({%Game{} = game, join_code}) do
    Process.flag(:trap_exit, true)
    Horde.Registry.register(SessionJoinCodeRegistry, join_code, self())
    game_state = get_stashed_state(game.id, game)

    {:ok, {game_state, join_code}, {:continue, :continue_game}}
  end

The test is passing and I now have a function to look up game state by join code. In the game LiveView mount callback, I replace the call to get_game with the new get_game_by_join_code and use that game state result to set the socket assigns for :game_id.

defmodule MinotaurWeb.GameLive do
  # …

 def mount(%{"join_code" => join_code}, _, socket) do
    %{current_user: current_user} = socket.assigns

    with {:ok, game} <- GameEngine.get_game_by_join_code(join_code),
         {:ok, status} <- GameEngine.get_player_status(game, current_user.id) do
      socket =
        socket
        |> assign(:status, status)
        |> assign(:game_id, game.id)

      join_game(socket, game, status)
    else
      {:error, :game_not_alive} ->
        {:ok, push_navigate(assign(socket, :notice, :game_not_live), to: "/")}

      {:error, :player_not_found} ->
        {:ok, push_navigate(assign(socket, :notice, :player_not_found), to: "/")}

      _ ->
        {:ok, assign(socket, :notice, :unknown_mount_error)}
    end
  end

  # …
end

This change should allow me to use different values for the game id and the session join_code which is what I’ll tackle next.

UUID for game id

I add the uuid package to my project mix.exs file under deps and run mix deps.get to install it.

defp deps do
  [
    # …
    {:uuid, "~> 1.1"}
  ]
end

When a new game is created by calling GameEngine.create_game(join_code, users), a new Game struct is created by the GameEngine.Session.create_game/2 function which is currently being passed the join_code as its first argument assigning that value to the game id.

def module Minotaur.GameEngine do
  # …

  def create_game(join_code, users) do
    {:ok, game} = Session.create_game(join_code, users)
    spec = SessionServer.child_spec(join_code: join_code, game_state: game)
    Horde.DynamicSupervisor.start_child(SessionSupervisor, spec)
  end
end

Since join_code now has no relation to the Game struct, I will remove the join_code argument from GameEngine.Session.create_game function definition and instead generate a unique game id each time the function is called. I create a new test case to start working toward this desired behavior.

defmodule Minotaur.GameEngine.Session.CreateGameTest do
  alias Minotaur.GameEngine.Session

  describe "create_game/2 with users" do
    setup [:create_users]

    test "should create a unique game id", %{users: users} do
      {:ok, game1} = Session.create_game("AAAA", users)
      {:ok, game2} = Session.create_game("AAAA", users)
      {:ok, game3} = Session.create_game("AAAA", users)

      assert game1.id != game2.id
      assert game1.id != game3.id
      assert game2.id != game3.id
    end

    # …
  end
end

Before breaking the existing function definition by removing the game_id argument, I will first change the underlying behavior until the test passes. Once the behavior is updated, I’ll refactor the function to remove the unused argument.

The test is currently failing since the game sessions are all using the same game_id argument which is being set to the game id value. A simple change to the create_game function allows the test to pass:

  def create_game(_game_id, [_ | _] = users) do
    game_id = UUID.uuid4()
    world = generate_world()
    players = create_players(users)
    game = %Game{id: game_id, players: players, world: world}

    {:ok, game}
  end

Now that the test is passing, I remove the unused game_id argument from the definition and from all callers of the function.

def module Minotaur.GameEngine do
  # …

  def create_game(join_code, users) do
    {:ok, game} = Session.create_game(users)
    spec = SessionServer.child_spec(join_code: join_code, game_state: game)
    Horde.DynamicSupervisor.start_child(SessionSupervisor, spec)
  end
end

These recent changes were all to make persisting game records in the database with a unique identifier. I will next return to building the get_active_games_for_user function that I was working on the other week.