How to remove entries from nested Maps with string and atom keys

Hi all, pretty new to the forum although I’ve been lead here countless times already when googling answers :slight_smile: Thanks all who contribute here.
I’m new to Elixir and using Phoenix (and Vuejs) to create a turn-based TC game . Clients communicate through sockets, game states are stored in a GenServer (which will probably also persist the data to Postgres at some point since I already use PG to hold accounts and etc).

My question though is related to mapping the game state. When a game starts a map container with general info and each players info is generated. I then communicate this game state through sockets to each client - but since part of the map should be invisible to the opposing player I need to clear that out from each broadcast (so player 1 gets specific data from player 2 removed from the message, and vice-versa).

First I tried doing this:

def clean_player(:player_2 , state) do
    put_in(state, ["player_2", :deck], false)
    put_in(state, ["player_2", :decklist], false)
    put_in(state, ["player_2", :grimoire], false)
    put_in(state, ["player_2", :sideboard], false)
    state
end

(I eventually |> piped state into each put_in but then just to make sure I wrote it like that)
If I IO.puts state["player_2"][:deck] I will see the key, but the put_in doesn’t really do anything. It doesn’t raise an error but it also leaves the map unchanged. So I switched to creating a new hash map, using info from the existing map and setting the keys I want “invisible” to whatever, like so:

%{
      players: state[:players],
      status: state[:status],
      player_1: state["player_1"],
      player_2: %{
        username: state["player_2"][:username],
        deck: false,
        decklist: false,
        life: 20,
        aether: state["player_2"][:aether],
        grimoire: false,
        graveyard: state["player_2"][:graveyard],
        play: state["player_2"][:play],
        exile: state["player_2"][:exile],
        sideboard: false,
        time: state["player_2"][:time],
        last_activity: state["player_2"][:last_activity],
        player: state["player_2"][:player],
        on_play: state["player_2"][:on_play],
        played_flux: state["player_2"][:played_flux],
        roll: state["player_2"][:roll],
        id: state["player_2"][:id]
      },
      you: "player_1",
      opponent: "player_2",
      messages: state[:messages]
    }

And this works, but my question is why can’t I replace the values with put_in? Is it because I’m using a string based key and atoms together? I tried searching but couldn’t find anything mentioning this in the docs, I would expect it to work. Or is it something silly on my end?

If it is because of the atoms do you have any idea of how I could write:
|> Map.put(“player_#{number}”, %{
username: player.username,


})

So to have the map key dynamically created but as an atom? Probably the way I’m doing it is very ruby like…

Lastly, is there any real drawback to using maps with atom keys in such a situation? Like, imagining I had thousands of players at the same time, would this matter?

Thanks

1 Like

There is a (old) blogpost on building Poker with Elixir where there is a similar situation, You should not see the other hands when updating state.

In the channel, as we can see in the source code there is an intercept update, that will catch broadcast before they go out. Then the state is cleaned with update_in for each players

defmodule GenPoker.HandChannel do
  use GenPoker.Web, :channel
  alias Poker.Hand

  intercept ["update"]

  ...

  def handle_out("update", state, socket) do
    push socket, "update", hide_other_hands(state, socket)
    {:noreply, socket}
  end

  defp hide_other_hands(state, socket) do
    player_id = socket.assigns.player_id
    hide_hand_if_current_player = fn
      %{id: ^player_id} = player -> player
      player -> Map.delete(player, :hand)
    end

    update_in(state.players, fn players ->
      Enum.map(players, hide_hand_if_current_player)
    end)
  end
end

Does it help?

1 Like

I think the problem is they’re nested, so if I use delete I will always need to copy the key where I want to delete the subkeys?

I’m using:

def join("duel:" <> id, payload, socket) do
    case authorized?(payload, socket, id) do
      {:ok, socket} ->
        case Monitor.game_stats(id) do
          [] ->
            stats = Monitor.game_create(id)
          game ->
            stats = game
        end
        stats = Processor.clean_stats(stats, socket.assigns.current_user)
        {:ok, %{user_id: socket.assigns.current_user, game: stats}, socket}
      {:error, _} ->
        {:error}
    end
 end

Which in turn calls:

def clean_stats(state, id) do
    state[:players]
    |> Enum.find(fn {key, val} -> val != id end)
    |> elem(0)
    |> clean_player(state)
  end

Perhaps I should redesign the state map so that those keys are only one level deep, but I’ll end up with a less less structured map, like

%{ player_1_deck: ....,
      player_2_deck: .... }

instead of

%{player_1: %{ deck ... },
     player_2: %{ deck ...}
}

The issue is that part of the players game state is public and part of it is not - the fact that I can’t access that in a regular way makes me think that the structure isn’t correct? As elixir is great in disallowing stupid design decisions…

I also prefer this stucture with players as key

BTW There is a drawback using dynamicaly generated atoms, because they are limited, but it’s fine to use a set of predefined atoms. The limit is over a million…

Atoms are not garbage collected.

So, forgive my noobness - I haven’t studied CS - but does that mean that each key I assign as an :atom instead of a “string” in this map counts towards that limit?
As in something like:
%{key_1: 1, key_2: 2, key_3: 3, key_4: 4}
would count 4 atoms? And then if I have a GenServer handling 4 processes with this same map, but different values, it would count 4x4? so 16 unique atoms?
Or is it relative to each process?
Because that can actually be a problem if they’re all counted (it seems it’s the VM that keeps this count?) since the tree has many nested keys, and each deck is composed of 60 cards, each one with at least another 10 keys.

Funny thing, I just did the math, so if each state has around 1500 keys in its map, 1M dividing by 1500 is 666.6666… Sorry if the question is really dumb.

Thanks,

It does count the number of defined atoms, like :player_1, :player_2, but not each time you assign values to them :slight_smile:

What would not be good is to use :player_n with n is an increasing number.

Please have a look at Monitoring Erlang atoms as it does explain much better than I do.

Remember to translate from Erlang

1> erlang:memory(atom).

to Elixir

iex> :erlang.memory(:atom)

In your code you are changing the same map over and over again, and then returning the original:

def clean_player(:player_2 , state) do
  put_in(state, ["player_2", :deck], false)
  put_in(state, ["player_2", :decklist], false)
  put_in(state, ["player_2", :grimoire], false
  put_in(state, ["player_2", :sideboard], false)
  state
end

put_in doesn’t mutate state, but rather returns a new copy that contains the mutations. So you would need something like:

def clean_player(:player_2 , state) do
  state
  |> put_in(["player_2", :deck], false)
  |> put_in(["player_2", :decklist], false)
  |> put_in(["player_2", :grimoire], false
  |> put_in(["player_2", :sideboard], false)
end

Something like this might also work (not tested):

def clean_player(:player_2, state) do
  # create a new map by passing every key/value pair in state[:player_2] to the
  # clean_secrets(pair) function
  cleaned_player = Map.new(state[:player_2], &clean_secrets/1)
  # add the cleaned player_2 map back to the state and emit it
  %{state | player_2: cleaned_player}
end

# given a {key, value} pair from the players deck, decide whether or not to clean it
@secrets_to_clean [:deck, :decklist, :grimoire, :sideboard]
def clean_secrets({key, _}) when key in @secrets_to_clean, do {key, false}
def clean_secrets(item), do: item

Map.new/2 takes a map and creates a new one using a function that updates each key/value pair from the old map (it receives each pair as a {key, value} tuple). So clean_secrets just needs to deal with keeping or cleaning single {key, value} items from the users map. Then we need to add the new updated map back to the overall state and return it.

clean_player also needn’t be specific to the player, since I imagine it could work for any. You could do something like this to make it work for any players in @valid_players.

@valid_players = [:player_1, :player_2]
def clean_player(player, state) when player in @valid_players do
  cleaned_player = Map.new(state[player], &clean_secrets/1)
  %{state | player => cleaned_player}
end
4 Likes

Kyle thanks, indeed it works - I guess when I piped I was referring to the player key as an atom when it was a string and then along the way noticed it, changed it to refer correctly but was no longer using the piping so it didn’t work as expected. I knew it must have been something silly as it made sense it would work…

And I like the idea of refactoring it further and keeping an array of blacklisted keys to remove, thanks for sharing I will also implement it as it looks much cleaner :smiley: