How to manage state for each player in Phoenix

So looking at what you said you have:

Websocket connection between a client and your server
A gen_server that holds the game state for that client.

Storing the game in the socket is possible but it has many issues - a disconnect/network/socket problem will throw out the state. No bueno unless you’re saving it somewhere external. You can solve it but I think it will add complexity where it doesn’t need to.

Any process in erlang can be named and this is something that you can use here if your players have an unique identifying property (a user id, a token, etc). To use non-atom names you need to either register them globally or by Registry (or any other module that implements the registry required things), using the :via options.

You would (on channel join) ask if the server for the player was running, if yes you would request the state from it, if not, you would start a fresh genserver giving the unique identifier to be used as part of its name. This takes care of the multiple channels/tabs issue. If you want to know when all sockets are disconnected to perhaps clean up, you could also monitor the channel pids and set an appropriate timeout in case all channels go down (meaning the user disconnected and didn’t reconnect in a sensible timeframe)
So on your socket you could have something like:

def join("user:" <> id, %{"token" => token}, socket) do
	# do validation to check if it's valid user etc
	{n_id, _} = String.to_integer(id)
	case start_or_state(n_id) do
		{:ok, state} -> 
			{:ok, state, assign(socket, :player, id}
		{:error, reason} ->
			{:error, reason}
	end
end

def handle_in("make_a_move", params, %{assigns: %{player: id}} = socket) do
    case GenservModule.move(id, params) do
      {:ok, response} ->
        {:reply, {:ok, response}, socket}
      {:error, errors} ->
        {:reply, {:error, %{errors: errors}}, socket}
    end
end

Then on your genserver module you would have a function as part of its public api start_or_state/1

def gen_serv_name(id), do: {:global, {:game, id}}

def start_or_state(id) when is_integer(id) do
	{:ok, pid} = case GenServer.whereis(gen_serv_ref(id)) do
				nil -> 
                                    case GenServer.start(__MODULE__, {n_id, self()}, name: gen_serv_ref(id)) do
                                       {:ok, pid} -> {:ok, pid}
                                       {:error, {:already_started, pid}} -> {:ok, pid}
                                    end
                                    #or start&link it by a dynamic supervisor, or add it to a supervisor tree, etc
				pid -> {:ok, pid}
			   end
	GenServer.call(pid, :get_state)
end

def move(id, params) do
	# maybe verify params, etc
	GenServer.call(gen_serv_ref(id), {:move, params})
end

def init({id, channel_pid}) do
	# if you also store the game state somewhere else you could see if it was stored and feed it, otherwise if it's transient, just start fresh, etc
	monitor_ref = Process.monitor(channel_pid)
	monitors_map = Map.put(%{}, pid, monitor_ref)
	{:ok, %{my_game_state: %{}, monitors: monitors_map}}
end

def handle_call(:get_state, {pid, _tag} = _caller, %{monitors: monitors, my_game_state: gs} = state) when :erlang.is_map_key(pid, monitors) do
	# because we have the guard is_map_key we know we don't need to add this channel (the caller) to the monitors
	{:reply, {:ok, gs}, state}	
end

def handle_call(:get_state, {pid, _tag} = _caller, %{monitors: monitors, my_game_state: gs} = state)
	# here we know this channel pid isn't being monitored (happens if a new tab is open as it will only ask for the state and we won't be monitoring that channel unless we add it here
	monitor_ref = Process.monitor(pid)
	n_monitors = Map.put(monitors, pid, monitor_ref)
	{:reply, {:ok, gs}, %{state | monitors: n_monitors}}
end

def handle_call({:move, params}, _, %{my_game_state: gs} = state) do
	n_game_state = GameEngine.do_stuff(gs, params)
	{:reply, {:ok, n_game_state}, %{state | my_game_state: n_game_state}}
end

# now because you're monitoring the channels we need a handle for any :DOWN messages coming from channels that die
def handle_info({:DOWN, ref, :process, pid, _reason}, %{monitors: monitors} = state) do
    {^ref, n_monitors} = Map.pop(monitors, pid)
    n_state = %{state | monitors: n_monitors}
    case n_monitors do
	_ when n_monitors == %{} -> 
		# there are no active channels for this user, lets set a timeout
		{:noreply, n_state, 25_000}
	_ ->
		# there's still some active channel no need to set timeout
		{:noreply, n_state}
    end
end

# and the timeout handle - if the user disconnects from all channels and doesn't reconnect in 25_000ms this message will be received and in this case shutdown the server
def handle_info(:timeout, state) do
	{:stop, :normal, state}
end

You could (and maybe should) add a monitor on the channels for the game gen_server itself - although technically you should make it so it’s not possible to crash it things out of your control might make it crash - so that the channel can be notified if the game crashes and do whatever is needed. This depends also on how you design the access to the genserver for the game updates, etc, it might not be needed depending on that…