Home Posts Post Search Tag Search

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

We are almost done here we have a few things to clean up first.

Redirect the User if they try to access a room that doesn’t exist

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

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

        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
          |> put_flash(:error, "That room does not exist.")
          |> push_navigate(to: "/multi-poker")

        {:ok, socket}
    end
  end

Only allow a user to join the table if it is not at max player count

# MultiPokerLive
  @impl true
  def render(assigns) do
    ~H"""
    ...
      <div column-2>
        <GameBoard.join_game
          viewer_state={@viewer_state}
          room_status={@room.room_status}
          room_full={room_full(@room)}
        />
        ...
      </div>
    <% end %>
    """
  end

  defp room_full(%Room{players: players}) do
    map_size(players) >= 6
  end

# GameBoard
  attr(:viewer_state, :map, required: true)
  attr(:room_status, :atom, required: true)
  attr(:room_full, :boolean, required: true)

  def join_game(assigns) do
    ~H"""
    <div class="flex justify-center">
      <%= if @room_status == :waiting and @viewer_state.action_state == :not_joined and not @room_full do %>
        <button phx-click="join-game" class="px-4 py-2 bg-blue-600 text-white rounded">
          Join Game
        </button>
      <% end %>

      <%= if @viewer_state.action_state != :not_joined do %>
        <button phx-click="leave-game" class="px-4 py-2 bg-red-600 text-white rounded">
          Leave Game
        </button>
      <% end %>
    </div>
    """
  end

Remove ability to leave game while a hand is active.

# GameBoard
  attr(:viewer_state, :map, required: true)
  attr(:room_status, :atom, required: true)
  attr(:room_full, :boolean, required: true)

  def join_game(assigns) do
    ~H"""
    <div class="flex justify-center">
      <%= if @room_status == :waiting and @viewer_state.action_state == :not_joined and not @room_full do %>
        <button phx-click="join-game" class="px-4 py-2 bg-blue-600 text-white rounded">
          Join Game
        </button>
      <% end %>

      <%= if @viewer_state.action_state != :not_joined and @room_status == :waiting do %>
        <button phx-click="leave-game" class="px-4 py-2 bg-red-600 text-white rounded">
          Leave Game
        </button>
      <% end %>
    </div>
    """
  end

Add in the Blinds to the players.

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

  def set_blinds(%Room{players: players} = room) do
    {small_blind_player, big_blind_player} = find_next_seated_players_for_blinds(room)

    new_players =
      players
      |> Map.update(small_blind_player.player_id, small_blind_player, fn player ->
        Player.change(player,
          current_bet: room.small_blind,
          chips: player.chips - room.small_blind
        )
      end)
      |> Map.update(big_blind_player.player_id, big_blind_player, fn player ->
        Player.change(player,
          current_bet: room.big_blind,
          chips: player.chips - room.big_blind
        )
      end)

    room
    |> Room.change(current_round_max_bet: room.big_blind)
    |> Room.change(players: new_players)
  end

  defp find_next_seated_players_for_blinds(%Room{dealer_player_id: dealer_player_id} = room) do
    small_blind_player = next_seated_player(room, dealer_player_id)

    big_blind_player =
      case small_blind_player do
        nil -> nil
        player -> next_seated_player(room, player.player_id)
      end

    {small_blind_player, big_blind_player}
  end

< br />

At the Showdown the first Player gets one more action.

# GameLogic
  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)
        |> end_hand()
    end
  end

Ready indicator for UI so show which players need to ready up.

# GameBoard
  attr(:players, :map, required: true)
  attr(:current_player_turn, :integer, required: true)
  attr(:community_cards, :list, required: true)
  attr(:current_viewer_id, :string, required: true)

  def game_table(assigns) do
    ~H"""
    <div class="space-y-6">
      <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
        <div class="text-center font-medium mb-3">Community Cards</div>
        <.community_cards community_cards={@community_cards} />
      </div>

      <div class="space-y-4">
        <%= for {_id, player} <- @players do %>
          <.player_hand
            player_id={player.player_id}
            player_hand={player.hand}
            current_player_turn={@current_player_turn}
            show_hand={player.viewer_id == @current_viewer_id}
            chips={player.chips}
            current_bet={player.current_bet}
            folded?={player.folded?}
            ready?={player.ready?}
          />
        <% end %>
      </div>
    </div>
    """
  end

  attr(:player_id, :integer, required: true)
  attr(:player_hand, :list, required: true)
  attr(:current_player_turn, :integer, required: false, default: nil)
  attr(:show_hand, :boolean, required: true)
  attr(:chips, :integer, required: true)
  attr(:current_bet, :integer, required: true)
  attr(:folded?, :boolean, required: true)
  attr(:ready?, :boolean, required: true)

  def player_hand(assigns) do
    ~H"""
    <div class={[
      "rounded-xl border bg-white p-4 shadow-sm",
      @ready? && "border-red-500",
      (not @ready? and @player_id == @current_player_turn) &&
        "border-green-500 ring-2 ring-green-200",
      (not @ready? and @player_id != @current_player_turn) &&
        "border-gray-200"
    ]}>
      <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
        <div class="md:flex-1">
          <div class="mb-2 font-medium text-left">
            Player {@player_id}
            <%= if @player_id == @current_player_turn do %>
              <span class="ml-2 text-green-600">(Current Turn)</span>
            <% end %>
            <%= if @folded? do %>
              <span class="ml-2 text-red-600">(Folded)</span>
            <% end %>
          </div>

          <div class="flex justify-start">
            <.card_row cards={@player_hand} total_slots={2} reveal?={@show_hand} />
          </div>
        </div>

        <div class="min-w-[180px] rounded-lg bg-gray-50 p-3 text-sm text-gray-700 md:text-right">
          <div class="font-semibold text-gray-900 mb-2">Player Stats</div>
          <div>Chips: {@chips}</div>
          <div>Current Bet: {@current_bet}</div>
        </div>
      </div>
    </div>
    """
  end

Add green border to the winning hand and show all cards

# Room
  defstruct players: %{},
            room_id: nil,
            room_status: :waiting,
            host_id: nil,
            max_players: 6,
            phase: :pre_flop,
            deck: [],
            community_cards: [],
            small_blind: 50,
            big_blind: 100,
            current_player_turn: nil,
            pot: 0,
            current_hand_number: 0,
            dealer_player_id: nil,
            current_round_max_bet: 0,
            winning_hand: nil,
            winning_player_id: nil

  @allowed_keys [
    :players,
    :room_id,
    :room_status,
    :host_id,
    :max_players,
    :phase,
    :deck,
    :community_cards,
    :small_blind,
    :big_blind,
    :current_player_turn,
    :pot,
    :current_hand_number,
    :dealer_player_id,
    :current_round_max_bet,
    :winning_hand,
    :winning_player_id
  ]

  def new(%Player{} = host, opts \\ []) do
    host_id = host.player_id

    %__MODULE__{
      players: %{host_id => host},
      room_id: Keyword.get(opts, :room_id),
      room_status: Keyword.get(opts, :room_status, :waiting),
      host_id: host_id,
      max_players: Keyword.get(opts, :max_players, 6),
      phase: Keyword.get(opts, :phase, :pre_flop),
      deck: Keyword.get(opts, :deck, []),
      community_cards: Keyword.get(opts, :community_cards, []),
      small_blind: Keyword.get(opts, :small_blind, 50),
      big_blind: Keyword.get(opts, :big_blind, 100),
      current_player_turn: Keyword.get(opts, :current_player_turn, host_id),
      pot: Keyword.get(opts, :pot, 0),
      current_hand_number: Keyword.get(opts, :current_hand_number, 0),
      dealer_player_id: Keyword.get(opts, :dealer_player_id, host_id),
      current_round_max_bet: Keyword.get(opts, :current_round_max_bet, 0),
      winning_hand: Keyword.get(opts, :winning_hand, nil),
      winning_player_id: Keyword.get(opts, :winning_player_id, nil)
    }
  end

# GameLogic
  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,
            winning_player_id: winner_player_id
        }

      :error ->
        room
    end
  end

  defp reset_room_for_new_hand(%Room{} = room) do
    Room.change(room,
      phase: :pre_flop,
      deck: [],
      community_cards: [],
      pot: 0,
      current_hand_number: room.current_hand_number + 1,
      current_round_max_bet: 0,
      winning_hand: nil,
      winning_player_id: nil
    )
  end

# GameBoard
  attr(:players, :map, required: true)
  attr(:current_player_turn, :integer, required: true)
  attr(:community_cards, :list, required: true)
  attr(:current_viewer_id, :string, required: true)
  attr(:phase, :atom, required: true)
  attr(:winning_player_id, :integer, required: true)

  def game_table(assigns) do
    ~H"""
    <div class="space-y-6">
      <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
        <div class="text-center font-medium mb-3">Community Cards</div>
        <.community_cards community_cards={@community_cards} />
      </div>

      <div class="space-y-4">
        <%= for {_id, player} <- @players do %>
          <.player_hand
            player_id={player.player_id}
            player_hand={player.hand}
            current_player_turn={@current_player_turn}
            show_hand={player.viewer_id == @current_viewer_id or @phase == :showdown}
            chips={player.chips}
            current_bet={player.current_bet}
            folded?={player.folded?}
            ready?={player.ready?}
            winning_player_id={@winning_player_id}
          />
        <% end %>
      </div>
    </div>
    """
  end

  attr(:player_id, :integer, required: true)
  attr(:player_hand, :list, required: true)
  attr(:current_player_turn, :integer, required: false, default: nil)
  attr(:show_hand, :boolean, required: true)
  attr(:chips, :integer, required: true)
  attr(:current_bet, :integer, required: true)
  attr(:folded?, :boolean, required: true)
  attr(:ready?, :boolean, required: true)
  attr(:winning_player_id, :integer, required: true)

  def player_hand(assigns) do
    ~H"""
    <div class={player_border_class(assigns)}>
      <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
        <div class="md:flex-1">
          <div class="mb-2 font-medium text-left">
            Player {@player_id}
            <%= if @player_id == @current_player_turn do %>
              <span class="ml-2 text-green-600">(Current Turn)</span>
            <% end %>
            <%= if @folded? do %>
              <span class="ml-2 text-red-600">(Folded)</span>
            <% end %>
          </div>

          <div class="flex justify-start">
            <.card_row cards={@player_hand} total_slots={2} reveal?={@show_hand} />
          </div>
        </div>

        <div class="min-w-[180px] rounded-lg bg-gray-50 p-3 text-sm text-gray-700 md:text-right">
          <div class="font-semibold text-gray-900 mb-2">Player Stats</div>
          <div>Chips: {@chips}</div>
          <div>Current Bet: {@current_bet}</div>
        </div>
      </div>
    </div>
    """
  end

  defp player_border_class(assigns) do
    base = "rounded-xl border bg-white p-4 shadow-sm"

    cond do
      assigns.player_id == assigns.winning_player_id ->
        "#{base} border-green-600 ring-2 ring-green-300"

      assigns.ready? ->
        "#{base} border-red-500"

      assigns.player_id == assigns.current_player_turn ->
        "#{base} border-green-500 ring-2 ring-green-200"

      true ->
        "#{base} border-gray-200"
    end
  end

Add a marker to the current dealer. Also clean up the ScoreBoard so it shows less.

# MultiPokerLive
      <GameBoard.game_table
        players={@room.players}
        current_player_turn={@room.current_player_turn}
        community_cards={@room.community_cards}
        current_viewer_id={@current_viewer_id}
        phase={@room.phase}
        winning_player_id={@room.winning_player_id}
        dealer_player_id={@room.dealer_player_id}
      />

# GameBoard
  attr(:players, :map, required: true)
  attr(:current_player_turn, :integer, required: true)
  attr(:community_cards, :list, required: true)
  attr(:current_viewer_id, :string, required: true)
  attr(:phase, :atom, required: true)
  attr(:winning_player_id, :integer, required: true)
  attr(:dealer_player_id, :integer, required: false, default: nil)

  def game_table(assigns) do
    ~H"""
    <div class="space-y-6">
      <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
        <div class="text-center font-medium mb-3">Community Cards</div>
        <.community_cards community_cards={@community_cards} />
      </div>

      <div class="space-y-4">
        <%= for {_id, player} <- @players do %>
          <.player_hand
            player_id={player.player_id}
            player_hand={player.hand}
            current_player_turn={@current_player_turn}
            show_hand={
              player.viewer_id == @current_viewer_id or
                @phase == :showdown or
                not is_nil(@winning_player_id)
            }
            chips={player.chips}
            current_bet={player.current_bet}
            folded?={player.folded?}
            ready?={player.ready?}
            winning_player_id={@winning_player_id}
            dealer_player_id={@dealer_player_id}
          />
        <% end %>
      </div>
    </div>
    """
  end

  attr(:player_id, :integer, required: true)
  attr(:player_hand, :list, required: true)
  attr(:current_player_turn, :integer, required: false, default: nil)
  attr(:show_hand, :boolean, required: true)
  attr(:chips, :integer, required: true)
  attr(:current_bet, :integer, required: true)
  attr(:folded?, :boolean, required: true)
  attr(:ready?, :boolean, required: true)
  attr(:winning_player_id, :integer, required: true)
  attr(:dealer_player_id, :integer, required: false, default: nil)

  def player_hand(assigns) do
    ~H"""
    <div class={player_border_class(assigns)}>
      <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
        <div class="md:flex-1">
          <div class="mb-2 font-medium text-left">
            Player {@player_id}

            <%= if @player_id == @dealer_player_id do %>
              <span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
                Dealer
              </span>
            <% end %>

            <%= if @player_id == @current_player_turn do %>
              <span class="ml-2 text-green-600">(Current Turn)</span>
            <% end %>

            <%= if @folded? do %>
              <span class="ml-2 text-red-600">(Folded)</span>
            <% end %>
          </div>

          <div class="flex justify-start">
            <.card_row cards={@player_hand} total_slots={2} reveal?={@show_hand} />
          </div>
        </div>

        <div class="min-w-[180px] rounded-lg bg-gray-50 p-3 text-sm text-gray-700 md:text-right">
          <div class="font-semibold text-gray-900 mb-2">Player Stats</div>
          <div>Chips: {@chips}</div>
          <div>Current Bet: {@current_bet}</div>
        </div>
      </div>
    </div>
    """
  end

  attr(:phase, :atom, required: true)
  attr(:current_player_turn, :integer, required: false, default: nil)
  attr(:pot, :integer, required: true)
  attr(:dealer_player_id, :integer, required: false, default: nil)
  attr(:current_round_max_bet, :integer, required: true)
  attr(:winning_hand, :map, required: false, default: nil)

  def score_board(assigns) do
    ~H"""
    <section class="bg-white shadow-md rounded-xl p-4 border border-gray-200">
      <h2 class="text-lg font-semibold mb-4 text-center">Table Info</h2>

      <div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
        <div class="bg-gray-50 rounded-lg p-3">
          <p class="text-sm text-gray-500">Phase</p>
          <p class="text-base font-medium">{format_phase(@phase)}</p>
        </div>

        <div class="bg-gray-50 rounded-lg p-3">
          <p class="text-sm text-gray-500">Pot</p>
          <p class="text-base font-medium">{@pot}</p>
        </div>

        <div class="bg-gray-50 rounded-lg p-3">
          <p class="text-sm text-gray-500">Current Round Max Bet</p>
          <p class="text-base font-medium">{@current_round_max_bet}</p>
        </div>
      </div>
    </section>
    """
  end

Fold and All-in Logic

# Player
  defstruct player_id: nil,
            viewer_id: nil,
            ready?: false,
            chips: 1000,
            current_bet: 0,
            folded?: false,
            seat_position: nil,
            hand: [],
            connected?: true,
            waiting?: false,
            total_contribution: 0

  def new(player_id, viewer_id, opts \\ []) do
    ready = Keyword.get(opts, :ready?, false)
    chips = Keyword.get(opts, :chips, 1000)
    current_bet = Keyword.get(opts, :current_bet, 0)
    folded = Keyword.get(opts, :folded?, false)
    seat_position = Keyword.get(opts, :seat_position, nil)
    hand = Keyword.get(opts, :hand, [])
    connected = Keyword.get(opts, :connected?, true)
    waiting = Keyword.get(opts, :waiting?, false)
    total_contribution = Keyword.get(opts, :total_contribution, 0)

    %__MODULE__{
      player_id: player_id,
      viewer_id: viewer_id,
      ready?: ready,
      chips: chips,
      current_bet: current_bet,
      folded?: folded,
      seat_position: seat_position,
      hand: hand,
      connected?: connected,
      waiting?: waiting,
      total_contribution: total_contribution
    }
  end
# GameLogic
# Add this line to any player_bet or all in
# total_contribution: player.total_contribution + amount,

  defp maybe_advance_round(%Room{} = room) do
    players = Map.values(room.players)

    in_hand =
      Enum.reject(players, & &1.folded?)

    can_act =
      Enum.reject(players, fn p -> p.folded? or p.chips == 0 end)

    cond do
      length(in_hand) <= 1 ->
        end_hand_by_fold(room)

      can_act == [] ->
        auto_runout_to_end(room)

      betting_round_complete?(room) ->
        advance_phase_and_deal(room)

      true ->
        advance_to_next_player(room)
    end
  end

  defp auto_runout_to_end(%Room{phase: :river} = room) do
    room
    |> Room.change(phase: :showdown)
    |> end_hand()
  end

  defp auto_runout_to_end(%Room{} = room) do
    room
    |> advance_phase_and_deal()
    |> auto_runout_to_end()
  end

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

  def resolve_winner_by_fold(%Room{} = room) do
    active_players =
      room.players
      |> Map.values()
      |> Enum.reject(& &1.folded?)

    case active_players do
      [] ->
        room

      [%Player{player_id: winner_player_id} | _rest] ->
        award_pot(room, winner_player_id, nil)
    end
  end

Redo the Logic for Restarts so Rooms Don’t Auto Restart

# Room
  use GenServer, restart: :temporary

Add in a Badge to Show Which Player You Are

  attr(:players, :map, required: true)
  attr(:current_player_turn, :integer, required: true)
  attr(:community_cards, :list, required: true)
  attr(:current_viewer_id, :string, required: true)
  attr(:phase, :atom, required: true)
  attr(:winning_player_id, :integer, required: false, default: nil)
  attr(:dealer_player_id, :integer, required: false, default: nil)

  def game_table(assigns) do
    ~H"""
    <div class="space-y-6">
      <div class="rounded-xl border border-gray-200 bg-white p-4 shadow-sm">
        <div class="text-center font-medium mb-3">Community Cards</div>
        <.community_cards community_cards={@community_cards} />
      </div>

      <div class="space-y-4">
        <%= for {_id, player} <- @players do %>
          <.player_hand
            player_id={player.player_id}
            player_hand={player.hand}
            current_player_turn={@current_player_turn}
            show_hand={
              player.viewer_id == @current_viewer_id or
                @phase == :showdown or
                not is_nil(@winning_player_id)
            }
            chips={player.chips}
            current_bet={player.current_bet}
            folded?={player.folded?}
            ready?={player.ready?}
            winning_player_id={@winning_player_id}
            dealer_player_id={@dealer_player_id}
            is_current_viewer={player.viewer_id == @current_viewer_id}
          />
        <% end %>
      </div>
    </div>
    """
  end

  attr(:player_id, :integer, required: true)
  attr(:player_hand, :list, required: true)
  attr(:current_player_turn, :integer, required: false, default: nil)
  attr(:show_hand, :boolean, required: true)
  attr(:chips, :integer, required: true)
  attr(:current_bet, :integer, required: true)
  attr(:folded?, :boolean, required: true)
  attr(:ready?, :boolean, required: true)
  attr(:winning_player_id, :integer, required: false, default: nil)
  attr(:dealer_player_id, :integer, required: false, default: nil)
  attr(:is_current_viewer, :boolean, required: true)

  def player_hand(assigns) do
    ~H"""
    <div class={player_border_class(assigns)}>
      <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
        <div class="md:flex-1">
          <div class="mb-2 font-medium text-left">
            Player {@player_id}

            <%= if @player_id == @dealer_player_id do %>
              <span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-semibold text-blue-700">
                Dealer
              </span>
            <% end %>

            <%= if @is_current_viewer do %>
              <span class="ml-2 inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-700">
                You
              </span>
            <% end %>

            <%= if @player_id == @current_player_turn do %>
              <span class="ml-2 text-green-600">(Current Turn)</span>
            <% end %>

            <%= if @folded? do %>
              <span class="ml-2 text-red-600">(Folded)</span>
            <% end %>
          </div>

          <div class="flex justify-start">
            <.card_row cards={@player_hand} total_slots={2} reveal?={@show_hand} />
          </div>
        </div>

        <div class="min-w-[180px] rounded-lg bg-gray-50 p-3 text-sm text-gray-700 md:text-right">
          <div class="font-semibold text-gray-900 mb-2">Player Stats</div>
          <div>Chips: {@chips}</div>
          <div>Current Bet: {@current_bet}</div>
        </div>
      </div>
    </div>
    """
  end

Mark Players as Busted at the End of the Hand. Then Remove Them from the table at Hand Start. Also Remove the Ability to Ready Up.

# Player
  defstruct player_id: nil,
            viewer_id: nil,
            ready?: false,
            chips: 1000,
            current_bet: 0,
            folded?: false,
            seat_position: nil,
            hand: [],
            connected?: true,
            waiting?: false,
            total_contribution: 0,
            busted?: false

  def new(player_id, viewer_id, opts \\ []) do
    ready = Keyword.get(opts, :ready?, false)
    chips = Keyword.get(opts, :chips, 1000)
    current_bet = Keyword.get(opts, :current_bet, 0)
    folded = Keyword.get(opts, :folded?, false)
    seat_position = Keyword.get(opts, :seat_position, nil)
    hand = Keyword.get(opts, :hand, [])
    connected = Keyword.get(opts, :connected?, true)
    waiting = Keyword.get(opts, :waiting?, false)
    total_contribution = Keyword.get(opts, :total_contribution, 0)
    busted = Keyword.get(opts, :busted?, false)

    %__MODULE__{
      player_id: player_id,
      viewer_id: viewer_id,
      ready?: ready,
      chips: chips,
      current_bet: current_bet,
      folded?: folded,
      seat_position: seat_position,
      hand: hand,
      connected?: connected,
      waiting?: waiting,
      total_contribution: total_contribution,
      busted?: busted
    }
  end

# GameLogic
  def start_hand(%Room{} = room) do
    room
    |> remove_busted_players()
    |> advance_to_next_dealer()
    |> reset_room_for_new_hand()
    |> reset_players_for_new_hand()
    |> set_blinds()
    |> 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_and_busted()
    |> Room.change(
      room_status: :waiting,
      current_player_turn: nil
    )
  end

  def end_hand_by_fold(%Room{} = room) do
    room
    |> resolve_winner_by_fold()
    |> reset_players_to_not_ready_and_busted()
    |> Room.change(
      room_status: :waiting,
      current_player_turn: nil
    )
  end

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

        {player_id, updated_player}
      end)

    %Room{room | players: new_players}
  end

  defp can_start_hand?(%Room{room_status: :waiting, players: players}) do
    eligible_players =
      players
      |> Map.values()
      |> Enum.reject(& &1.busted?)

    length(eligible_players) >= 2 and Enum.all?(eligible_players, & &1.ready?)
  end

  defp can_start_hand?(_state), do: false

  defp remove_busted_players(%Room{players: players} = room) do
    new_players =
      players
      |> Enum.reject(fn {_player_id, player} -> player.busted? end)
      |> Map.new()

    %Room{room | players: new_players}
  end

# 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,
          busted?: false
        }

      %Player{} = player ->
        action_state =
          cond do
            player.busted? -> :busted
            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?,
          busted?: player.busted?
        }
    end
  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 ->
        case Map.get(state.players, player_id) do
          %{busted?: true} ->
            {:noreply, state}

          _player ->
            new_state =
              state
              |> GameLogic.mark_player_ready(player_id)
              |> GameLogic.maybe_start_hand()

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

            {:noreply, new_state}
        end
    end
  end

# GameBoard
  attr(:game_state, :atom, required: true)
  attr(:viewer_state, :map, required: true)

  def player_ready(assigns) do
    ~H"""
    <div class="flex justify-center">
      <%= if @game_state == :waiting and
            @viewer_state.action_state != :not_joined and
            not @viewer_state.ready? and not @viewer_state.busted? do %>
        <button phx-click="player-ready" class="px-4 py-2 bg-blue-600 text-white rounded">
          Ready!
        </button>
      <% end %>
    </div>
    """
  end

Add in a Helper Button that Will Show the Rules.

defmodule GameSiteWeb.MultiPokerLive.InstructionHelper do
  use Phoenix.Component

  def helper_bubble(assigns) do
    ~H"""
    <div id="poker-rules-help" phx-hook="HelpBubble" class="relative inline-block">
      <button
        type="button"
        data-help-button
        class="inline-flex h-8 w-8 items-center justify-center rounded-full bg-gray-200 text-sm font-bold text-gray-700 hover:bg-gray-300"
        aria-label="Show game rules"
      >
        ?
      </button>

      <div
        data-help-panel
        class="absolute left-1/2 top-full z-50 mt-2 w-80 -translate-x-1/2 rounded-xl border border-gray-200 bg-white p-4 shadow-lg"
      >
        <h3 class="mb-2 text-sm font-semibold text-gray-900">Poker Rules</h3>

        <div class="space-y-2 text-sm text-gray-700">
          <p>Each player is dealt 2 hole cards.</p>
          <p>Betting happens across pre-flop, flop, turn, and river.</p>
          <p>If all but one player folds, that player wins immediately.</p>
          <p>If all remaining players are all-in, the board runs out automatically.</p>
          <p>The best 5-card hand wins.</p>
          <p>
            If you bust, you can come back with 1000 chips after leaving the table or being removed.
          </p>
        </div>
      </div>
    </div>
    """
  end
end


# MultiPokerLive
@impl true
  def render(assigns) do
    ~H"""
    <%= if @room == nil  do %>
      Room is loading...
    <% else %>
      <GameBoard.score_board
        phase={@room.phase}
        current_player_turn={@room.current_player_turn}
        pot={@room.pot}
        dealer_player_id={@room.dealer_player_id}
        current_round_max_bet={@room.current_round_max_bet}
        winning_hand={@room.winning_hand}
      />
      <div class="ml-4">
        <InstructionHelper.helper_bubble />
      </div>

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

      <GameBoard.player_actions
        room_status={@room.room_status}
        action_state={@viewer_state.action_state}
        player_chips={@viewer_state.player_chips}
        player_current_bet={@viewer_state.player_current_bet}
        bet_amount={get_current_min_bet_needed(@room, @current_viewer_id)}
      />

      <div column-2>
        <GameBoard.join_game
          viewer_state={@viewer_state}
          room_status={@room.room_status}
          room_full={room_full(@room)}
        />
        <GameBoard.player_ready game_state={@room.room_status} viewer_state={@viewer_state} />
      </div>
    <% end %>
    """
  end

#app.js
let Hooks = {}

Hooks.HelpBubble = {
  mounted() {
    const button = this.el.querySelector("[data-help-button]")
    const panel = this.el.querySelector("[data-help-panel]")

    if (!button || !panel) return

    panel.style.display = "none"

    const closePanel = (event) => {
      if (!this.el.contains(event.target)) {
        panel.style.display = "none"
      }
    }

    const togglePanel = () => {
      panel.style.display = panel.style.display === "none" ? "block" : "none"
    }

    button.addEventListener("click", togglePanel)
    document.addEventListener("click", closePanel)

    this.cleanup = () => {
      button.removeEventListener("click", togglePanel)
      document.removeEventListener("click", closePanel)
    }
  },

  destroyed() {
    this.cleanup?.()
  }
}

Wow that is everything that I wanted from my Poker Game. Take some time and write exhaustive test for every module.