Multiplayer Toy Robot

Hi! You might know that I’m writing a book about the Toy Robot exercise in Elixir: The Elixir Toy Robot (self-published). I’ve written an extra chapter at the end of this book that uses GenServers, Supervisors and Registry to build a single-player Toy Robot.

I thought I could figure out on my own how to turn this into a multi-player Toy Robot, but I’m struggling with how to code this in Elixir in an easy-to-understand way.

For a bit of context, here is the code as it stands today at the end of this single-player chapter: https://github.com/radar/toy_robot_elixir/commit/175f80c2e3ea64a46cc695ced4bcf145e160723a.

What I’m thinking I need here is a Game module that tracks the table that the players would move around on, as well as the current placement for all players. Something like this:

  use GenServer

  alias ToyRobot.Table
  alias ToyRobot.Game.PlayerSupervisor

  def start_link(args) do
    GenServer.start_link(__MODULE__, args, name: __MODULE__)
  end

  def init([north_boundary: north_boundary, east_boundary: east_boundary]) do
    table = %Table{
      north_boundary: north_boundary,
      east_boundary: east_boundary,
    }

    {:ok, %{table: table, players: %{}}}
  end

  def add_player(name, position) do
    GenServer.call(__MODULE__, {:add_player, name, position})
  end

  def handle_call({:add_player, name, position}, from, state) do
    if space_occupied?(state.players, position) do
      {:reply, {:error, :occupied}, state}
    else
      {:ok, player} = PlayerSupervisor.start_child(position, name)
      players = state.players |> Map.put(name, position)
      {:reply, {:ok, player}, %{state | players: players}}
    end
  end

  def space_occupied?(players, position) do
    positions = players |> Map.values |> Enum.map(&coordinates/1)
    (position |> coordinates) in positions
  end

  defp coordinates(position), do: position |> Map.take([:north, :east])
end

This handles the initial placement of the players on the game table. A small detail not handled here is validating that the specified position is within the boundaries of the table. That’s something I know how to do, so don’t worry about that one.

What I am worried about here is what happens when a player’s process dies. This can happen if a player’s robot moves past the boundaries of the table. What I would like to be prevented here is two robots occupying the same space and that means also preventing that when the robot respawns.

How should I do this? Should I get the Player server here to know about Game and know how to ask questions like “is my initial spawn point taken?” and then to somehow pick a random position on the board to spawn in? Or is there something else that I am overlooking here?

Any advice would be greatly appreciated!

3 Likes

Should I get the Player server here to know about Game and know how to ask questions like “is my initial spawn point taken?” and then to somehow pick a random position on the board to spawn in?

Could you get each Player to ask the Game to place them on the board (and return back the placement info)? I think the Game has the central/coordination state, and so is best placed to control access to it.

3 Likes

I would also let the server be in charge of positioning… something like this.

  def add_player(name) do
    GenServer.call(__MODULE__, {:add_player, name})
  end

  def handle_call({:add_player, name}, from, state) do
    position = generate_random_and_not_occupied_position()
    {:ok, player} = PlayerSupervisor.start_child(position, name)
    players = state.players |> Map.put(name, position)
    {:reply, {:ok, player, position}, %{state | players: players}}
  end

BTW It’s funny because I am toying with something similar, but instead of robots, I control babylon js meshes…

    ...
    position = Babylon.random_position()

    player = %{
      model: Babylon.random_shape(),
      colour: Babylon.random_color(),
      animation: "Idle",
      x: position.x, y: 2, z: position.z,
      rx: 0, ry: 0, rz: 0
    }
   ...
    push(socket, "world_sync", %{player: player, world: world})
2 Likes

What about cases where the Player process crashes? Here’s an imagined flow:

  1. Game starts Player #1 process, E: 0, N: 0, F: NORTH
  2. Game starts Player #2 process, E: 1, N: 0, F: WEST
  3. Player #1 moves off E: 0, N: 0 to E: 0, N: 1
  4. Player #2 moves onto E: 0, N: 0
  5. Player #1’s process crashes (somehow)
  6. Player #1’s process has initial state of E: 0, N: 0 , but Player #2 is now on that square.
    1. Player #1 should not be placed on this square.
    2. Player #1 should be placed on ???

Should the Player process during Player.init/1 ask the Game if a square is taken, and if so then ask the Game for a random position on the board and re-attempt placement? Is that going to be the easiest path forward here?

1 Like

I would think that the game would place the player. So in step 6 player 1 would ask to be placed at E: 0, N: 0 but the game would instead place it somewhere else according to some algorithm (in this case it is as simple as E: 1, N: 0)

1 Like

That makes sense. Thanks!

I’ve been toying around with the code today and I think I’ve come up with a solution that will work:. Here’s a branch containing the code https://github.com/radar/toy_robot_elixir/tree/multi-prototype.

In the multi directory, there are three Elixir script files that check:

  1. A robot cannot start on an already occupied square
  2. A robot cannot move onto an occupied square
  3. A robot cannot respawn on an occupied square – it instead starts on a random one

All the changes required for this are in this commit: https://github.com/radar/toy_robot_elixir/commit/10555043408f91605c5f94f8b002532c7f0a9374

What I’ve done here is make the ToyRobot.Game module responsible for all the “input” for the game. There’s now a ToyRobot.Game.Table GenServer that keeps a track of the Table struct, as well as the current position of all players. That current position is set in two places: once when the Player.init/1 function gets called (so we can update the position when a process dies + a new one starts up), and once when Game.move is called. I think this sort of separation across modules feels a bit… murky, but I am not sure how to clean it up.

Other than that, the code feels good to me, but it doesn’t hurt to get a sanity check on these sorts of things. Is there anything in particular that I should do differently? Do I need to create more modules? Is there’s a pitfall you might see happening here?

I just found out why it was “murky” for me: it was because both Table and Player knew about the positions that a robot was occupying. Essentially two sources of truth.

So what I’ve done now is make it so that the Table can ask all (alive) players what their current position is. Here’s the most recent commit: https://github.com/radar/toy_robot_elixir/commit/2edc745