Home Posts Post Search Tag Search

PokerLive - 06 - PubSub
Published on: 2026-04-05 Tags: elixir, Blog, Side Project, LiveView, Game Site, Phoenix, Poker, GenServer, Player, Room, Supervisor Tree, Game Logic

Okay so today I want to add in the logic for the PubSub. This will take care of 2 things. First it will make sure that anyone that is at the “/multi-poker” route will be updated on any new rooms that are created and that the state of the Room will be updated at anytime that a user takes an action. This will make sure that every user will know the current state of the room. Let’s start off with a new Module PubSub.

defmodule GameSite.MultiPoker.PubSub do
  alias Phoenix.PubSub

  @pubsub GameSite.PubSub
  @lobby_topic "multi_poker:lobby"

  def lobby_topic, do: @lobby_topic

  def room_topic(room_id), do: "multi_poker:room:#{room_id}"

  def subscribe_lobby do
    PubSub.subscribe(@pubsub, lobby_topic())
  end

  def subscribe_room(room_id) do
    PubSub.subscribe(@pubsub, room_topic(room_id))
  end

  def broadcast_lobby_updated do
    PubSub.broadcast(@pubsub, lobby_topic(), {:lobby_updated})
  end

  def broadcast_room_updated(room) do
    PubSub.broadcast(@pubsub, room_topic(room.room_id), {:room_updated, room})
  end
end

This is a small module that is just here to allow a LiveView to subscribe to a topic, or broadcast to that topic. You should be able to see that we have a subscribe_lobby/0 and a subscribe_room/1. These will leverage the @ variables at the top to allow a LiveView to (onmount) subscribe to the topic. The _subscribe_room/1 will need to pass the room id as there might be quite a few rooms alive at any given point.


Now that we have this setup we need to add that to the Lobby so that we can keep the rooms up-to-date.

defmodule GameSiteWeb.MultiPokerLive.Lobby do
  use GameSiteWeb, :live_view

  alias GameSite.MultiPoker.{Room, PubSub}

  ...

  @impl true
  def mount(_params, session, socket) do
    socket =
      socket
      |> set_current_viewer_id(session)

    rooms =
      if connected?(socket) do
        PubSub.subscribe_lobby()
        list_room_summaries()
      else
        []
      end

    {:ok, assign(socket, :rooms, rooms)}
  end

  @impl true
  def handle_info({:lobby_updated}, socket) do
    rooms = list_room_summaries()

    {:noreply, assign(socket, :rooms, rooms)}
  end

  @impl true
  def handle_event("create_room", _params, socket) do
    current_viewer_id = socket.assigns.current_viewer_id

    {socket, room_id} =
      case MultiPoker.create_room(current_viewer_id) do
        {_, room_id} ->
          {socket, room_id}

        {:error, :already_has_room, room_id} ->
          socket =
            assign(socket, :rooms, list_room_summaries())

          {socket, room_id}
      end

    PubSub.broadcast_lobby_updated()

    {:noreply, redirect(socket, to: "/multi-poker/#{room_id}")}
  end

  ...

  def set_current_viewer_id(socket, _session) do
    assign(socket, :current_viewer_id, :not_signed_in)
  end
end

I only put the new or updated versions of the functions, but you can see that on mount we subscribe and then when a room is created we broadcast.


Now that we have that let’s add some functionality for the Room, keeping in mind that at any point if we change anything in the room we will need to broadcast in order for everyone to see any changes to the room state. We will start by adding in functionality to “join-game.”

#Room
defmodule GameSite.MultiPoker.Room do
  use GenServer

  alias GameSite.MultiPoker.{GameLogic, Player, PubSub}

  ...

  @impl true
  def handle_call({:add_player, viewer_id}, _from, %__MODULE__{} = state) do
    case find_player_id_by_viewer_id(state, viewer_id) do
      nil ->
        case next_player_id(state) do
          nil ->
            {:reply, {:error, :room_full}, state}

          player_id ->
            player = Player.new(player_id, viewer_id)
            new_players = Map.put(state.players, player_id, player)
            new_state = %__MODULE__{state | players: new_players}

            PubSub.broadcast_room_updated(new_state)

            {:reply, {:ok, player}, new_state}
        end

      player_id ->
        {:reply, {:ok, Map.fetch!(state.players, player_id)}, state}
    end
  end

  ...
end

# MultiPokerLive
defmodule GameSiteWeb.MultiPokerLive do
  use GameSiteWeb, :live_view

  alias GameSite.MultiPoker.{GameLogic, Room, Player, PubSub}

  ...
  @impl true
  def render(assigns) do
    ~H"""
    <%= if @room == nil  do %>
      Room is loading...
    <% else %>
      <p>Room Status: {@room.room_status}</p>
      <p>Small Blind: {@room.small_blind}</p>
      <p>Big Blind: {@room.big_blind}</p>

      <p>Deck:</p>
      <pre><%= inspect(@room.deck) %></pre>
      <GameBoard.score_board
        phase={@room.phase}
        current_player_turn={@room.current_player_turn}
        pot={@room.pot}
        dealer_player_id={@room.dealer_player_id}
      />

      <GameBoard.game_table
        players={@room.players}
        current_player_turn={@room.current_player_turn}
        community_cards={@room.community_cards}
        current_viewer_id={@current_viewer_id}
      />

      <GameBoard.player_actions
        action_state={@viewer_state.action_state}
        player_chips={@viewer_state.player_chips}
        bet_amount={0}
      />

      <GameBoard.join_game viewer_state={@viewer_state} />
    <% end %>
    """
  end

  @impl true
  def mount(params, session, socket) do
    socket =
      socket
      |> set_current_viewer_id(session)

    if connected?(socket) do
      case MultiPoker.get_room(params["room"]) do
        {:ok, room} ->
          PubSub.subscribe_room(room.room_id)

          viewer_state = Room.viewer_state(room, socket.assigns.current_viewer_id)

          socket =
            socket
            |> assign(:room, room)
            |> assign(:viewer_state, viewer_state)
            |> assign(:form, to_form(%{}))

          {:ok, socket}

        :error ->
          socket =
            socket
            |> assign(:room, :bad_room)
            |> assign(:form, to_form(%{}))

          {:ok, socket}
      end
    else
      {:ok, assign(socket, :room, nil)}
    end
  end

  @impl true
  def handle_info({:room_updated, room}, socket) do
    viewer_state = Room.viewer_state(room, socket.assigns.current_viewer_id)

    {:noreply,
     socket
     |> assign(room: room)
     |> assign(:viewer_state, viewer_state)}
  end

  @impl true
  def handle_event(
        "join-game",
        _params,
        %{assigns: %{current_viewer_id: viewer_id, room: %Room{room_id: room_id}}} = socket
      ) do
    MultiPoker.add_player(room_id, viewer_id)

    {:noreply, socket}
  end
  ...
end

Okay we now have the logic we need to add a player. What is great about this is we don’t need to worry about dealing with constantly keeping the state of the room updated within the LiveView the handle_info does that for us and if we always make sure to broadcast after any update to the Room with the Room module we are fine.


Let’s work on betting next. This will follow the same logic but in this case we are sending a bet amount to the MultiPoker.

#MultiPokerLive
  @impl true
  def handle_event(
        "player-bet",
        %{"bet_amount" => bet_amount},
        %{assigns: %{current_viewer_id: viewer_id, room: %Room{room_id: room_id}}} = socket
      ) do
    amount = String.to_integer(bet_amount)
    MultiPoker.player_bet(room_id, viewer_id, amount)

    {:noreply, socket}
  end

#MultiPoker
  def player_bet(room_id, viewer_id, amount) do
    with {:ok, pid} <- get_room_pid(room_id) do
      Room.player_bet(pid, viewer_id, amount)
    end
  end

# Room
  @impl true
  def handle_cast({:player_bet, viewer_id, amount}, %__MODULE__{} = state) do
    case find_player_id_by_viewer_id(state, viewer_id) do
      nil ->
        {:noreply, state}

      player_id ->
        new_state = GameLogic.player_bet(state, player_id, amount)

        if new_state != state, do: PubSub.broadcast_room_updated(new_state)

        {:noreply, new_state}
    end
  end

# GameBoard
  attr(:disabled, :boolean, required: true)
  attr(:bet_amount, :integer, required: false, default: 0)
  attr(:player_chips, :integer, required: true)

  def player_controls(assigns) do
    ~H"""
    <div class="space-y-4">
      <form phx-submit="player-bet" class="space-y-4">
        <div class="max-w-xs mx-auto">
          <label for="bet_amount" class="block text-sm font-medium text-gray-700 mb-1">
            Bet Amount
          </label>
          <input
            id="bet_amount"
            name="bet_amount"
            type="number"
            min="1"
            max={@player_chips}
            value={@bet_amount}
            disabled={@disabled}
            class={[
              "w-full rounded-lg border px-3 py-2 shadow-sm focus:outline-none",
              "border-gray-300 focus:ring-2 focus:ring-blue-500",
              @disabled && "bg-gray-100 cursor-not-allowed opacity-70"
            ]}
          />
        </div>

        <div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
          <button
            type="button"
            phx-click="player-fold"
            disabled={@disabled}
            class={button_class("bg-red-500 hover:bg-red-600", @disabled)}
          >
            Fold
          </button>

          <button
            type="button"
            phx-click="player-check"
            disabled={@disabled}
            class={button_class("bg-gray-500 hover:bg-gray-600", @disabled)}
          >
            Check
          </button>

          <button
            type="submit"
            disabled={@disabled}
            class={button_class("bg-blue-500 hover:bg-blue-600", @disabled)}
          >
            Bet
          </button>

          <button
            type="button"
            phx-click="player-all-in"
            disabled={@disabled}
            class={button_class("bg-yellow-500 hover:bg-yellow-600", @disabled)}
          >
            All In
          </button>
        </div>
      </form>
    </div>
    """
  end

So that is a new event that will work for us. Go ahead and test it out. It should allow you to bet and then it will advance to the next player. Take some time now to add in the other events that we have within the player_actions component.


Okay I quickly wanted to work on the “leave-game” logic as it will help you to reset the room if you get into a bad state while testing everything that you add.

# MultiPokerLive
  @impl true
  def handle_info({:room_closed, _room_id}, socket) do
    {:noreply, push_navigate(socket, to: "/multi-poker")}
  end

  def handle_event(
        "leave-game",
        _params,
        %{assigns: %{current_viewer_id: viewer_id, room: %Room{room_id: room_id}}} = socket
      ) do
    MultiPoker.player_leave_game(room_id, viewer_id)

    {:noreply, socket}
  end

# MultiPoker
  def player_leave_game(room_id, viewer_id) do
    with {:ok, pid} <- get_room_pid(room_id) do
      Room.player_leave_game(pid, viewer_id)
    end
  end

# Room
  def player_leave_game(pid, viewer_id) do
    GenServer.cast(pid, {:remove_player, viewer_id})
  end

  @impl true
  def handle_cast({:remove_player, viewer_id}, %__MODULE__{} = state) do
    case find_player_id_by_viewer_id(state, viewer_id) do
      nil ->
        {:noreply, state}

      player_id ->
        if player_id == state.host_id do
          PubSub.broadcast_room_closed(state.room_id)
          PubSub.broadcast_lobby_updated()

          {:stop, :normal, state}
        else
          new_state =
            state
            |> remove_player(player_id)
            |> maybe_advance_turn(player_id)

          if new_state != state do
            PubSub.broadcast_room_updated(new_state)
          end

          {:noreply, new_state}
        end
    end
  end

defp remove_player(%__MODULE__{} = state, player_id) do
  new_players = Map.delete(state.players, player_id)
  %__MODULE__{state | players: new_players}
end

defp maybe_advance_turn(%__MODULE__{} = state, player_id) do
  if state.current_player_turn == player_id do
    GameLogic.advance_to_next_player(state)
  else
    state
  end
end

# PubSub
  def broadcast_room_closed(room_id) do
    PubSub.broadcast(@pubsub, room_topic(room_id), {:room_closed, room_id})
  end

Okay we now have the logic that we need for this. First let’s talk about the new handle_event that takes care of the “leave-game” event. As we follow the logic we will get to the Room logic for this event. This event is responsible for checking to see if the viewer_id is the host, if so we need to delete the room and then broadcast new rooms to the Lobby, if not the we need to update the Room and then broadcast the new state of the room. Going back to the MultiPokerLive we see an other handle_info that will deal with the :room_closed event and that makes sure to reroute the users to the Lobby if the room is stopped.


We will now set the event so start the game, with a “ready” event. This will be a button that will tell the room that the player is ready to start the hand and then the GameLogic.start_hand will run.

# MultiPokerLive
  @impl true
  def render(assigns) do
    ~H"""
    <%= if @room == nil  do %>
      Room is loading...
    <% else %>
      ...
      <div column-2>
        <GameBoard.join_game viewer_state={@viewer_state} />
        <GameBoard.player_ready game_state={@room.room_status} />
      </div>
    <% end %>
    """
  end

  @impl true
  def handle_event(
        "ready",
        _params,
        %{assigns: %{current_viewer_id: viewer_id, room: %Room{room_id: room_id}}} = socket
      ) do
    MultiPoker.player_ready(room_id, viewer_id)

    {:noreply, socket}
  end

# MultiPoker
  def player_ready(room_id, viewer_id) do
    with {:ok, pid} <- get_room_pid(room_id) do
      Room.player_ready(pid, viewer_id)
    end
  end

# Room
  def player_ready(pid, viewer_id) do
    GenServer.cast(pid, {:player_ready, viewer_id})
  end

  @impl true
  def handle_cast({:player_ready, viewer_id}, state) do
    case find_player_id_by_viewer_id(state, viewer_id) do
      nil ->
        {:noreply, state}

      player_id ->
        new_state =
          state
          |> mark_player_ready(player_id)
          |> maybe_start_hand()

        if new_state != state, do: PubSub.broadcast_room_updated(new_state)

        {:noreply, new_state}
    end
  end

  defp mark_player_ready(%__MODULE__{} = state, player_id) do
    case Map.fetch(state.players, player_id) do
      {:ok, player} ->
        updated_player = Player.change(player, ready?: true)
        new_players = Map.put(state.players, player_id, updated_player)
        %__MODULE__{state | players: new_players}

      :error ->
        state
    end
  end

  defp maybe_start_hand(%__MODULE__{} = state) do
    if can_start_hand?(state) do
      state
      |> change(room_status: :playing)
      |> GameLogic.start_hand()
    else
      state
    end
  end

  defp can_start_hand?(%__MODULE__{room_status: :waiting, players: players}) do
    map_size(players) >= 2 and Enum.all?(Map.values(players), & &1.ready?)
  end

  defp can_start_hand?(_state), do: false

Okay now we have all we need to start a Hand. This will take care of the logic to see if all players are ready.


We will now work on the logic to start and advance a round. We will start with the logic to start the game using the event “player-ready” this will let the Room know that we are ready to start the Hand.

# MultiPokerLive
  @impl true
  def handle_event(
        "player-ready",
        _params,
        %{assigns: %{current_viewer_id: viewer_id, room: %Room{room_id: room_id}}} = socket
      ) do
    MultiPoker.player_ready(room_id, viewer_id)

    {:noreply, socket}
  end

#  MultiPoker
  def player_ready(room_id, viewer_id) do
    with {:ok, pid} <- get_room_pid(room_id) do
      Room.player_ready(pid, viewer_id)
    end
  end

# Room
  def player_ready(pid, viewer_id) do
    GenServer.cast(pid, {:player_ready, viewer_id})
  end

  @impl true
  def handle_cast({:player_ready, viewer_id}, state) do
    case find_player_id_by_viewer_id(state, viewer_id) do
      nil ->
        {:noreply, state}

      player_id ->
        new_state =
          state
          |> mark_player_ready(player_id)
          |> maybe_start_hand()

        if new_state != state, do: PubSub.broadcast_room_updated(new_state)

        {:noreply, new_state}
    end
  end

# GameLogic
  def start_hand(%Room{} = room) do
    room
    |> advance_to_next_dealer()
    |> reset_room_for_new_hand()
    |> reset_players_for_new_hand()
    |> shuffle_new_deck()
    |> deal_player_hole_cards()
    |> set_first_player_turn()
  end

  def advance_phase_and_deal(%Room{phase: phase} = room) do
    case phase do
      :new_hand ->
        start_hand(room)

      :pre_flop ->
        room
        |> reset_players_for_new_round()
        |> deal_community_cards(3, :flop)
        |> set_first_player_turn

      :flop ->
        room
        |> reset_players_for_new_round()
        |> deal_community_cards(1, :turn)
        |> set_first_player_turn

      :turn ->
        room
        |> reset_players_for_new_round()
        |> deal_community_cards(1, :river)
        |> set_first_player_turn

      :river ->
        room
        |> Room.change(phase: :showdown)
        # Set all players to waiting: true
        |> set_first_player_turn

      :showdown ->
        room
        |> end_hand()
    end
  end

  defp maybe_advance_round(%Room{} = room) do
    if betting_round_complete?(room) do
      advance_phase_and_deal(room)
    else
      advance_to_next_player(room)
    end
  end

  defp betting_round_complete?(%Room{
         players: players,
         current_round_max_bet: current_round_max_bet
       }) do
    active_players =
      players
      |> Map.values()
      |> Enum.reject(& &1.folded?)

    case active_players do
      [] ->
        true

      [_one_player_left] ->
        true

      _ ->
        Enum.all?(active_players, fn player ->
          player.chips == 0 or
            (player.waiting? and player.current_bet == current_round_max_bet)
        end)
    end
  end

  def mark_player_ready(%Room{} = state, player_id) do
    case Map.fetch(state.players, player_id) do
      {:ok, player} ->
        updated_player = Player.change(player, ready?: true)
        new_players = Map.put(state.players, player_id, updated_player)
        %Room{state | players: new_players}

      :error ->
        state
    end
  end

  def maybe_start_hand(%Room{} = state) do
    if can_start_hand?(state) do
      state
      |> Room.change(room_status: :playing)
      |> start_hand()
    else
      state
    end
  end

  defp can_start_hand?(%Room{room_status: :waiting, players: players}) do
    map_size(players) >= 2 and Enum.all?(Map.values(players), & &1.ready?)
  end

  defp can_start_hand?(_state), do: false

This should be all you need for the advancement of a round. Each time that we do anything we need to check if we are able to advance the round. That check will worry about the players that aren’t folded, then testing against the amount of players, the check to see that all the players have the same value as the current_round_max_bet. Lastly you can see that each round will be advanced by the advance_phase_and_deal depending on the current phase of the Hand.


Okay so finally for this one we will work on the logic for dealing with the endgame. Everything that we have so far will allow us to simply work within the GameLogic module. We will however add in a new Module HandEvaluator that will help us rank all the hands.

# HandEvaluator
defmodule GameSite.MultiPoker.HandEvaluator do
  alias GameSite.MultiPoker.{Player, Room}

  def evaluate_hands(%Room{community_cards: community_cards, players: players}) do
    players
    |> Enum.map(fn {_id, %Player{hand: hand, player_id: player_id}} ->
      {player_id, community_cards ++ hand}
    end)
    |> Enum.sort_by(&rank_hand/1, :desc)
  end

  def rank_hand({_player_id, cards}) do
    score_hand(cards)
  end

  def score_hand(cards) do
    cond do
      royal_flush_value(cards) != nil ->
        {get_rank(:royal_flush), [royal_flush_value(cards)]}

      straight_flush_value(cards) != nil ->
        {get_rank(:straight_flush), [straight_flush_value(cards)]}

      four_of_a_kind_value(cards) != nil ->
        {get_rank(:four_of_a_kind), four_of_a_kind_value(cards)}

      full_house_value(cards) != nil ->
        {get_rank(:full_house), full_house_value(cards)}

      flush_value(cards) != nil ->
        {get_rank(:flush), flush_value(cards)}

      straight_value(cards) != nil ->
        {get_rank(:straight), [straight_value(cards)]}

      three_of_a_kind_value(cards) != nil ->
        {get_rank(:three_of_a_kind), three_of_a_kind_value(cards)}

      two_pair_value(cards) != nil ->
        {get_rank(:two_pair), two_pair_value(cards)}

      pair_value(cards) != nil ->
        {get_rank(:pair), pair_value(cards)}

      true ->
        {get_rank(:high_card), high_card_value(cards)}
    end
  end

  def get_rank(:high_card), do: 1
  def get_rank(:pair), do: 2
  def get_rank(:two_pair), do: 3
  def get_rank(:three_of_a_kind), do: 4
  def get_rank(:straight), do: 5
  def get_rank(:flush), do: 6
  def get_rank(:full_house), do: 7
  def get_rank(:four_of_a_kind), do: 8
  def get_rank(:straight_flush), do: 9
  def get_rank(:royal_flush), do: 10

  def royal_flush_value(cards) do
    case straight_flush_value(cards) do
      14 -> 14
      _ -> nil
    end
  end

  def straight_flush_value(cards) do
    cards
    |> Enum.group_by(fn {_value, suit} -> suit end, fn {value, _suit} -> value end)
    |> Enum.map(fn {_suit, values} ->
      values
      |> Enum.uniq()
      |> add_wheel_ace()
      |> Enum.sort()
      |> straight_high_card_from_values()
    end)
    |> Enum.reject(&is_nil/1)
    |> Enum.max(fn -> nil end)
  end

  def four_of_a_kind_value(cards) do
    counts = value_frequencies(cards)

    case Enum.find(counts, fn {_value, count} -> count == 4 end) do
      nil ->
        nil

      {quad_value, _count} ->
        kicker =
          counts
          |> Enum.reject(fn {value, _count} -> value == quad_value end)
          |> Enum.map(fn {value, _count} -> value end)
          |> Enum.max(fn -> nil end)

        [quad_value, kicker]
    end
  end

  def full_house_value(cards) do
    counts = value_frequencies(cards)

    trips =
      counts
      |> Enum.filter(fn {_value, count} -> count >= 3 end)
      |> Enum.map(fn {value, _count} -> value end)
      |> Enum.sort(:desc)

    case trips do
      [] ->
        nil

      [top_trip | remaining_trips] ->
        pair_candidates =
          counts
          |> Enum.reject(fn {value, count} ->
            value == top_trip or count < 2
          end)
          |> Enum.map(fn {value, _count} -> value end)

        pair_value =
          (remaining_trips ++ pair_candidates)
          |> Enum.sort(:desc)
          |> List.first()

        if pair_value do
          [top_trip, pair_value]
        else
          nil
        end
    end
  end

  def flush_value(cards) do
    cards
    |> Enum.group_by(fn {_value, suit} -> suit end, fn {value, _suit} -> value end)
    |> Enum.map(fn {_suit, values} ->
      if length(values) >= 5 do
        values
        |> Enum.sort(:desc)
        |> Enum.take(5)
      else
        nil
      end
    end)
    |> Enum.reject(&is_nil/1)
    |> Enum.max(fn -> nil end)
  end

  def straight_value(cards) do
    cards
    |> Enum.map(fn {value, _suit} -> value end)
    |> Enum.uniq()
    |> add_wheel_ace()
    |> Enum.sort()
    |> straight_high_card_from_values()
  end

  def three_of_a_kind_value(cards) do
    counts = value_frequencies(cards)

    case counts
         |> Enum.filter(fn {_value, count} -> count == 3 end)
         |> Enum.map(fn {value, _count} -> value end)
         |> Enum.sort(:desc)
         |> List.first() do
      nil ->
        nil

      trip_value ->
        kickers =
          counts
          |> Enum.reject(fn {value, _count} -> value == trip_value end)
          |> Enum.map(fn {value, _count} -> value end)
          |> Enum.sort(:desc)
          |> Enum.take(2)

        [trip_value | kickers]
    end
  end

  def two_pair_value(cards) do
    counts = value_frequencies(cards)

    pairs =
      counts
      |> Enum.filter(fn {_value, count} -> count >= 2 end)
      |> Enum.map(fn {value, _count} -> value end)
      |> Enum.sort(:desc)

    case pairs do
      [high_pair, low_pair | _rest] ->
        kicker =
          counts
          |> Enum.reject(fn {value, _count} -> value in [high_pair, low_pair] end)
          |> Enum.map(fn {value, _count} -> value end)
          |> Enum.max(fn -> nil end)

        [high_pair, low_pair, kicker]

      _ ->
        nil
    end
  end

  def pair_value(cards) do
    counts = value_frequencies(cards)

    case counts
         |> Enum.filter(fn {_value, count} -> count == 2 end)
         |> Enum.map(fn {value, _count} -> value end)
         |> Enum.sort(:desc)
         |> List.first() do
      nil ->
        nil

      pair_value ->
        kickers =
          counts
          |> Enum.reject(fn {value, _count} -> value == pair_value end)
          |> Enum.map(fn {value, _count} -> value end)
          |> Enum.sort(:desc)
          |> Enum.take(3)

        [pair_value | kickers]
    end
  end

  def high_card_value(cards) do
    cards
    |> Enum.map(fn {value, _suit} -> value end)
    |> Enum.uniq()
    |> Enum.sort(:desc)
    |> Enum.take(5)
  end

  defp straight_high_card_from_values(values) do
    values
    |> Enum.chunk_every(5, 1, :discard)
    |> Enum.filter(&consecutive?/1)
    |> Enum.map(&List.last/1)
    |> Enum.max(fn -> nil end)
  end

  defp consecutive?([a, b, c, d, e]) do
    b == a + 1 and c == b + 1 and d == c + 1 and e == d + 1
  end

  defp add_wheel_ace(values) do
    if 14 in values do
      [1 | values]
    else
      values
    end
  end

  defp value_frequencies(cards) do
    cards
    |> Enum.frequencies_by(fn {value, _suit} -> value end)
    |> Enum.to_list()
  end
end

# GameLogic
  def start_hand(%Room{} = room) do
    room
    |> advance_to_next_dealer()
    |> reset_room_for_new_hand()
    |> reset_players_for_new_hand()
    |> shuffle_new_deck()
    |> deal_player_hole_cards()
    |> set_first_player_turn()
  end

  def end_hand(%Room{} = room) do
    room
    |> resolve_winner_and_award_pot()
    |> reset_players_to_not_ready()
    |> Room.change(
      room_status: :waiting,
      current_player_turn: nil
    )
  end

  def advance_phase_and_deal(%Room{phase: phase} = room) do
    case phase do
      :new_hand ->
        start_hand(room)

      :pre_flop ->
        room
        |> reset_players_for_new_round()
        |> deal_community_cards(3, :flop)
        |> set_first_player_turn

      :flop ->
        room
        |> reset_players_for_new_round()
        |> deal_community_cards(1, :turn)
        |> set_first_player_turn

      :turn ->
        room
        |> reset_players_for_new_round()
        |> deal_community_cards(1, :river)
        |> set_first_player_turn

      :river ->
        room
        |> Room.change(phase: :showdown)
        # Set all players to waiting: true
        |> set_first_player_turn

      :showdown ->
        room
        |> end_hand()
    end
  end

  def resolve_winner_and_award_pot(%Room{} = room) do
    ordered_players = HandEvaluator.evaluate_hands(room)

    case ordered_players do
      [] ->
        room

      [{winner_player_id, winning_hand} | _rest] ->
        award_pot(room, winner_player_id, winning_hand)
    end
  end

  def award_pot(%Room{players: players, pot: pot} = room, winner_player_id, winning_hand) do
    case Map.fetch(players, winner_player_id) do
      {:ok, player} ->
        updated_player = Player.change(player, chips: player.chips + pot)
        new_players = Map.put(players, winner_player_id, updated_player)

        %Room{room | players: new_players, winning_hand: winning_hand}

      :error ->
        room
    end
  end

  def reset_players_to_not_ready(%Room{players: players} = room) do
    new_players =
      Enum.into(players, %{}, fn {player_id, player} ->
        updated_player =
          Player.change(player,
            ready?: false
          )

        {player_id, updated_player}
      end)

    %Room{room | players: new_players}
  end

The very last thing that I want to add is that because of all the changes that I made to the GameBoard I needed a more robust viewer_state

# Room
  def viewer_state(%__MODULE__{} = room, current_viewer_id) do
    case get_player_by_viewer_id_from_room(room, current_viewer_id) do
      nil ->
        %{
          player_id: nil,
          action_state: :not_joined,
          player_chips: 0,
          player_current_bet: 0,
          ready?: false
        }

      %Player{} = player ->
        action_state =
          cond do
            player.folded? -> :folded
            player.chips == 0 -> :all_in
            room.current_player_turn == player.player_id -> :your_turn
            true -> :waiting
          end

        %{
          action_state: action_state,
          player_chips: player.chips,
          player_current_bet: player.current_bet,
          player_id: player.player_id,
          ready?: player.ready?
        }
    end
  end