Should we use presence to store player position?

We are creating a multiplayer version of Pong. This is the architecture we’re currently using:

The browsers retrieve the position of the ball and the score from the GenServer by handling a tick message broadcast from the server. The position of the players is shared directly between browsers using PongWeb.Endpoint.broadcast_from.

Right after broadcasting the paddle position to the other browser, they update their paddle position in the presence using Presence.update.

The GenServer retrieves the position of each player from the presence using PongWeb.Presence.list so it can calculate the position of the ball or add a point to the scoreboard in the next tick (server ticks every 10 ms) .

The position of the ball and the scores are stored on the GenServer.

Although this is working, we are not sure if we should be using Presence to share the position of the players to the server. We realized that storing each player’s presence in the GenServer would be more straightforward. Another way to make things more simple is making the browsers retrieve the player’s positions from the Presence, instead of sharing messages between them.

What would be the best approach here?

For more context, here’s the code of our LiveView: game_live.ex

defmodule PongWeb.GameLive do
  use PongWeb, :live_view
  alias Pong.GameServer
  alias PongWeb.Presence

  def mount(%{"game_id" => game_id}, _session, socket) do
    # Start the GameServer for this game_id if it doesn't exist
    case DynamicSupervisor.start_child(Pong.GameSupervisor, {GameServer, game_id}) do
      {:ok, _pid} -> :ok
      {:error, {:already_started, _pid}} -> :ok
      {:error, reason} -> {:stop, reason}
    end

    # Generate a unique player ID
    player_id = :crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)

    # Get the current game state from the GameServer
    game_state = GameServer.get_state(game_id)

    # Assign initial values to the socket
    socket =
      socket
      |> assign(:player_id, player_id)
      |> assign(:game_id, game_id)
      # Initial paddle position for the player
      |> assign(:y_player, 50)
      # Initial paddle position for the opponent
      |> assign(:y_opponent, 50)
      # Default side until assigned
      |> assign(:side, :spectator)
      # Assign the game state from the GameServer
      |> assign(:ball, game_state.ball)
      |> assign(:score, game_state.score)

    if connected?(socket) do
      # Subscribe to the game topic for PubSub
      PongWeb.Endpoint.subscribe("game:#{game_id}")

      # Track the player's presence in the game
      Presence.track(self(), "game:#{game_id}", player_id, %{
        y_player: socket.assigns.y_player,
        side: socket.assigns.side
      })
    end

    {:ok, socket, layout: false}
  end

  def handle_event("cursor-move", %{"mouse_y" => y}, socket) do
    game_id = socket.assigns.game_id
    player_id = socket.assigns.player_id
    side = socket.assigns.side

    # Update own paddle position
    socket = assign(socket, :y_player, y)

    # Update presence metadata
    Presence.update(self(), "game:#{game_id}", player_id, %{
      y_player: y,
      side: side
    })

    # Broadcast the new position to other clients
    PongWeb.Endpoint.broadcast_from(self(), "game:#{game_id}", "paddle_update", %{
      "player_id" => player_id,
      "side" => side,
      "y" => y
    })

    {:noreply, socket}
  end

  # Handle presence updates to assign sides
  def handle_info(
        %Phoenix.Socket.Broadcast{
          event: "presence_diff",
          payload: %{joins: _joins, leaves: _leaves}
        },
        socket
      ) do
    presences = PongWeb.Presence.list("game:#{socket.assigns.game_id}")
    side = assign_side(presences, socket.assigns.player_id)
    {:noreply, assign(socket, :side, side)}
  end

  # Handle paddle updates from other players
  def handle_info(%Phoenix.Socket.Broadcast{event: "paddle_update", payload: payload}, socket) do
    %{"player_id" => player_id, "side" => side, "y" => y} = payload

    if player_id != socket.assigns.player_id and opposite_side?(socket.assigns.side, side) do
      {:noreply, assign(socket, :y_opponent, y)}
    else
      {:noreply, socket}
    end
  end

  # Handle game state updates from the GameServer
  def handle_info(
        %Phoenix.Socket.Broadcast{event: "game_state_update", payload: new_state},
        socket
      ) do
    {:noreply, assign(socket, ball: new_state.ball, score: new_state.score)}
  end

  # Helper functions
  defp opposite_side?(:left, :right), do: true
  defp opposite_side?(:right, :left), do: true
  defp opposite_side?(_, _), do: false

  defp assign_side(presences, player_id) do
    player_ids = Map.keys(presences) |> Enum.sort()

    cond do
      length(player_ids) == 1 ->
        :left

      length(player_ids) >= 2 ->
        [player1_id, player2_id | _] = player_ids

        cond do
          player_id == player1_id -> :left
          player_id == player2_id -> :right
          true -> :spectator
        end

      true ->
        :spectator
    end
  end
end

And here’s the code of our GenServer game_server.ex:

defmodule Pong.GameServer do
  use GenServer

  # Public API

  def start_link(game_id) do
    GenServer.start_link(__MODULE__, %{game_id: game_id}, name: via_tuple(game_id))
  end

  defp via_tuple(game_id) do
    {:via, Registry, {Pong.GameRegistry, game_id}}
  end

  def get_state(game_id) do
    GenServer.call(via_tuple(game_id), :get_state)
  end

  # GenServer Callbacks

  def init(state) do
    initial_state = %{
      game_id: state.game_id,
      ball: %{x: 50, y: 50, vx: -0.25, vy: 0.0},
      score: %{left: 0, right: 0},
      timer_ref: nil
    }

    # Start the game loop
    {:ok, schedule_tick(initial_state)}
  end

  def handle_info(:tick, state) do
    # Update the game state
    new_state = update_game_state(state)

    # Broadcast the new state to clients
    PongWeb.Endpoint.broadcast("game:#{state.game_id}", "game_state_update", %{
      ball: new_state.ball,
      score: new_state.score
    })

    # Schedule the next tick
    {:noreply, schedule_tick(new_state)}
  end

  def handle_call(:get_state, _from, state) do
    {:reply, state, state}
  end

  # Helper functions

  defp schedule_tick(state) do
    timer_ref = Process.send_after(self(), :tick, 10)
    Map.replace(state, :timer_ref, timer_ref)
  end

  defp update_game_state(state) do
    ball = state.ball
    score = state.score

    # Update ball position
    ball_left_border_pos_current = ball.x
    ball_top_border_pos_current = ball.y
    ball_speed_x_current = ball.vx
    ball_speed_y_current = ball.vy

    ball_height = 1.25
    # Assuming the ball is square
    ball_width = 1.25

    # Compute next positions
    ball_left_border_next = ball_left_border_pos_current + ball_speed_x_current
    ball_top_border_next = ball_top_border_pos_current + ball_speed_y_current
    ball_bottom_border_next = ball_top_border_next + ball_height
    ball_right_border_next = ball_left_border_next + ball_width
    ball_vertical_middle = ball_top_border_next + ball_height / 2

    # Paddle dimensions and positions
    paddle_sector_sizes = [45, 10, 45]

    # Retrieve paddle positions from Presence
    paddles = get_paddle_positions(state.game_id)

    # Left paddle
    {left_paddle_top, left_paddle_left_border} =
      case paddles.left do
        # Default values if paddle position not available
        nil -> {50, 6.25}
        y_player -> {y_player, 6.25}
      end

    # Paddle width is 1%
    left_paddle_right_border = left_paddle_left_border + 1

    {left_paddle_top_sector_bottom_limit, left_paddle_middle_sector_bottom_limit,
     left_paddle_bottom} =
      paddle_sectors(left_paddle_top, paddle_sector_sizes)

    # Right paddle
    {right_paddle_top, right_paddle_left_border} =
      case paddles.right do
        # Default values if paddle position not available
        nil -> {50, 93.75}
        y_player -> {y_player, 93.75}
      end

    # Paddle width is 1%
    right_paddle_right_border = right_paddle_left_border + 1

    {right_paddle_top_sector_bottom_limit, right_paddle_middle_sector_bottom_limit,
     right_paddle_bottom} =
      paddle_sectors(right_paddle_top, paddle_sector_sizes)

    # Check for collisions
    collision_with_left_paddle =
      float_in_range?(ball_left_border_next, left_paddle_left_border, left_paddle_right_border) and
        float_in_range?(ball_vertical_middle, left_paddle_top, left_paddle_bottom)

    collision_with_right_paddle =
      float_in_range?(ball_right_border_next, right_paddle_left_border, right_paddle_right_border) and
        float_in_range?(ball_vertical_middle, right_paddle_top, right_paddle_bottom)

    {ball_speed_x_next, ball_speed_y_next} =
      cond do
        collision_with_left_paddle ->
          handle_paddle_collision(
            ball_vertical_middle,
            left_paddle_top,
            {left_paddle_top_sector_bottom_limit, left_paddle_middle_sector_bottom_limit,
             left_paddle_bottom},
            ball_speed_x_current,
            ball_speed_y_current
          )

        collision_with_right_paddle ->
          handle_paddle_collision(
            ball_vertical_middle,
            right_paddle_top,
            {right_paddle_top_sector_bottom_limit, right_paddle_middle_sector_bottom_limit,
             right_paddle_bottom},
            ball_speed_x_current,
            ball_speed_y_current
          )

        ball_top_border_next < 0 ->
          {ball_speed_x_current, -ball_speed_y_current}

        ball_bottom_border_next > 100 ->
          {ball_speed_x_current, -ball_speed_y_current}

        true ->
          {ball_speed_x_current, ball_speed_y_current}
      end

    # Check for scoring and respawn ball if necessary
    {score, ball} =
      check_score_and_respawn_ball(
        ball_left_border_next,
        ball_right_border_next,
        score.left,
        score.right,
        ball_speed_x_next,
        ball_speed_y_next,
        ball_top_border_next
      )

    new_ball = %{
      x: ball.x,
      y: ball.y,
      vx: ball.vx,
      vy: ball.vy
    }

    %{state | ball: new_ball, score: score}
  end

  # Helper functions

  defp paddle_sectors(paddle_top, paddle_sector_sizes) do
    paddle_sector_sizes_absolute = Enum.map(paddle_sector_sizes, &(&1 * 20 / 100))
    paddle_top_sector_bottom_limit = paddle_top + Enum.at(paddle_sector_sizes_absolute, 0)

    paddle_middle_sector_bottom_limit =
      paddle_top_sector_bottom_limit + Enum.at(paddle_sector_sizes_absolute, 1)

    paddle_bottom = paddle_middle_sector_bottom_limit + Enum.at(paddle_sector_sizes_absolute, 2)

    {paddle_top_sector_bottom_limit, paddle_middle_sector_bottom_limit, paddle_bottom}
  end

  defp handle_paddle_collision(
         ball_vertical_middle,
         paddle_top,
         {paddle_top_sector_bottom_limit, paddle_middle_sector_bottom_limit, paddle_bottom},
         ball_speed_x_current,
         ball_speed_y_current
       ) do
    cond do
      float_in_range?(ball_vertical_middle, paddle_top, paddle_top_sector_bottom_limit) ->
        {-ball_speed_x_current, ball_speed_x_current}

      float_in_range?(
        ball_vertical_middle,
        paddle_top_sector_bottom_limit,
        paddle_middle_sector_bottom_limit
      ) ->
        {-ball_speed_x_current, ball_speed_y_current}

      float_in_range?(ball_vertical_middle, paddle_middle_sector_bottom_limit, paddle_bottom) ->
        {-ball_speed_x_current, -ball_speed_x_current}

      true ->
        {ball_speed_x_current, ball_speed_y_current}
    end
  end

  defp check_score_and_respawn_ball(
         ball_left_border_next,
         ball_right_border_next,
         left_player_current_points,
         right_player_current_points,
         _ball_speed_x_next,
         _ball_speed_y_next,
         _ball_top_border_next
       ) do
    cond do
      # Ball went past the right boundary
      ball_left_border_next > 100 ->
        {%{left: left_player_current_points + 1, right: right_player_current_points},
         %{x: 50, y: 50, vx: 25 / 100, vy: 0 / 100}}

      # Ball went past the left boundary
      ball_right_border_next < 0 ->
        {%{left: left_player_current_points, right: right_player_current_points + 1},
         %{x: 50, y: 50, vx: -25 / 100, vy: 0 / 100}}

      true ->
        {%{left: left_player_current_points, right: right_player_current_points},
         %{
           x: ball_left_border_next,
           y: _ball_top_border_next,
           vx: _ball_speed_x_next,
           vy: _ball_speed_y_next
         }}
    end
  end

  defp float_in_range?(float, min, max) do
    float >= min and float <= max
  end

  # Function to get paddle positions from Presence
  defp get_paddle_positions(game_id) do
    presences = PongWeb.Presence.list("game:#{game_id}")

    left_paddle =
      presences
      |> Enum.find(fn
        {_, %{metas: [%{side: :left}]}} -> true
        _ -> false
      end)
      |> case do
        {_, %{metas: [%{y_player: y}]}} -> y
        _ -> nil
      end

    right_paddle =
      presences
      |> Enum.find(fn
        {_, %{metas: [%{side: :right}]}} -> true
        _ -> false
      end)
      |> case do
        {_, %{metas: [%{y_player: y}]}} -> y
        _ -> nil
      end

    %{left: left_paddle, right: right_paddle}
  end
end

Any other feedback is welcome :slight_smile:

You don’t need more than the LiveView process. Implement the game logic in a pure functional way, using just a module defining a struct and the accompanying functions. Then use the LiveView events to drive the logic. As simple as that.

The main problem, in my view, is that your logic is somewhere in the lifecycle machinery. You can’t isolate and test it easily, you need to spawn processes and so on. I see this problem everywhere in Elixir projects. There are resources that shed light on the alternatives.

You might want to read up on:

3 Likes