Home Posts Post Search Tag Search

PokerLive - 05 - LiveView for the Room
Published on: 2026-03-31 Tags: elixir, Blog, LiveView, Game Site, Phoenix, Poker, GenServer, Player, Room, Game Logic

Okay so now I want to work on all the Components and the initial display for the MultiPokerLive liveview. This will involve us getting the Room from the GenServer that is named within the params.


First I want to work on a new plug that will allow a user to not be logged in so long as a logged in user has made a room. For this I want both Logged-in users and Guests to be able to have a guest_id that will be stored within the session.

# game_site_web/plugs/ensure_guest_id.ex
defmodule GameSiteWeb.Plugs.EnsureGuestId do
  import Plug.Conn

  def init(opts), do: opts

  def call(conn, _opts) do
    if get_session(conn, :guest_id) do
      conn
    else
      put_session(conn, :guest_id, System.unique_integer([:positive]))
    end
  end
end

# Now add that to the pipeline game_site_web/router.ex
...
  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {GameSiteWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    # This line here.
    plug GameSiteWeb.Plugs.EnsureGuestId
    plug :fetch_current_user
  end
...

With that set we need to now make a few changes to the code that we have within the Room, because right now we are letting the liveview set all the Player information we want to just pass in the current_viewer_id and then let the GenServer take care of the rest.

defmodule GameSite.MultiPoker.Player do
  defstruct player_id: nil,
            viewer_id: nil,
            ready?: false,
            chips: 1000,
            current_bet: 0,
            folded?: false,
            seat_position: nil,
            hand: [],
            connected?: true

  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)

    %__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
    }
  end

  def change(%__MODULE__{} = player, opts \\ []) do
    updates = Enum.into(opts, %{})
    struct(player, updates)
  end

  def set_ready(%__MODULE__{} = player, ready) do
    %__MODULE__{player | ready?: ready}
  end
end

defmodule GameSite.MultiPoker.Room do
  use GenServer

  alias GameSite.MultiPoker.{GameLogic, Player}

  defstruct players: %{},
            room_id: nil,
            room_status: :waiting,
            host_id: nil,
            max_players: 6,
            phase: :pre_flop,
            deck: [],
            community_cards: [],
            small_blind: 10,
            big_blind: 20,
            current_player_turn: nil,
            pot: 0,
            current_hand_number: 0,
            dealer_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
  ]

  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, 10),
      big_blind: Keyword.get(opts, :big_blind, 20),
      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)
    }
  end

  def change(%__MODULE__{} = room, opts) do
    valid_opts =
      Enum.filter(opts, fn {key, _value} ->
        key in @allowed_keys
      end)

    struct(room, valid_opts)
  end

  def start_link(%{room_id: room_id} = attrs) do
    GenServer.start_link(__MODULE__, attrs, name: via(room_id))
  end

  # viewer-facing actions

  def add_player(pid, viewer_id) do
    GenServer.call(pid, {:add_player, viewer_id})
  end

  def player_fold(pid, viewer_id) do
    GenServer.cast(pid, {:player_fold, viewer_id})
  end

  def player_bet(pid, viewer_id, amount) do
    GenServer.cast(pid, {:player_bet, viewer_id, amount})
  end

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

  def update_player(pid, viewer_id, opts) do
    GenServer.cast(pid, {:update_player, viewer_id, opts})
  end



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

      player_id ->
        {:noreply, GameLogic.player_fold(state, player_id)}
    end
  end

  @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 ->
        {:noreply, GameLogic.player_bet(state, player_id, amount)}
    end
  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 ->
        new_players = Map.delete(state.players, player_id)
        {:noreply, %__MODULE__{state | players: new_players}}
    end
  end

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

      player_id ->
        new_players =
          case Map.fetch(state.players, player_id) do
            {:ok, player} ->
              updated_player = Player.change(player, opts)
              Map.put(state.players, player_id, updated_player)

            :error ->
              state.players
          end

        {:noreply, %__MODULE__{state | players: new_players}}
    end
  end

  @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)

            {:reply, {:ok, player}, %__MODULE__{state | players: new_players}}
        end

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

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

      %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_id: player.player_id
        }
    end
  end

  defp find_player_id_by_viewer_id(%__MODULE__{} = state, viewer_id) do
    state.players
    |> Map.values()
    |> Enum.find_value(fn player ->
      if player.viewer_id == viewer_id, do: player.player_id
    end)
  end

  defp next_player_id(%__MODULE__{} = state) do
    used_ids = Map.keys(state.players)
    Enum.find(1..state.max_players, fn id -> id not in used_ids end)
  end
end

defmodule GameSite.MultiPoker do
  alias GameSite.MultiPoker.{Player, Room}
  alias Ecto.UUID

  @room_supervisor GameSite.MultiPoker.RoomSupervisor
  @registry GameSite.MultiPoker.RoomRegistry

  def create_room(viewer_id) do
    case get_room_by_host(viewer_id) do
      {room_id, _pid} ->
        {:error, :already_has_room, room_id}

      nil ->
        room_id = UUID.generate()
        host_player = Player.new(1, viewer_id)

        case DynamicSupervisor.start_child(
               @room_supervisor,
               {Room, %{room_id: room_id, host: host_player}}
             ) do
          {:ok, _pid} -> {:ok, room_id}
          error -> error
        end
    end
  end
end

defmodule GameSiteWeb.MultiPokerLive.Lobby do
  ...
  @impl true
  def mount(_params, session, socket) do
    socket =
      socket
      |> set_current_viewer_id(session)

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

    {:ok, 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

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

  def set_current_viewer_id(%{assigns: %{current_user: current_user}} = socket, _session)
      when not is_nil(current_user) do
    assign(socket, :current_viewer_id, "user:#{current_user.id}")
  end
end

With this you will now need to be sure that with any GameLogic you will pass the viewer_id and then convert it to the player_id. Okay so now that we have that we can allow a user to have something that is set by the site for any guest or logged in user so that we can use that to assign the current_viewer_id for any user. Let’s start to build the game_board.ex

defmodule GameSiteWeb.MultiPokerLive.GameBoard do
  use GameSiteWeb, :live_view
  use Phoenix.Component

  attr(:phase, :atom, required: true)
  attr(:current_player_turn, :integer, required: true)
  attr(:pot, :integer, required: true)
  attr(:dealer_player_id, :integer, required: true)

  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-2 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 Turn</p>
          <p class="text-base font-medium">
            {player_label(@current_player_turn)}
          </p>
        </div>

        <div class="bg-gray-50 rounded-lg p-3">
          <p class="text-sm text-gray-500">Dealer</p>
          <p class="text-base font-medium">
            {player_label(@dealer_player_id)}
          </p>
        </div>
      </div>
    </section>
    """
  end

  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">
      <%= 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}
        />
      <% end %>

      <.community_cards community_cards={@community_cards} />
    </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)

  def player_hand(assigns) do
    ~H"""
    <div class="space-y-2">
      <div class="text-center font-medium">
        Player {@player_id}
        <%= if @player_id == @current_player_turn do %>
          <span class="ml-2 text-green-600">(Current Turn)</span>
        <% end %>
      </div>

      <.card_row cards={@player_hand} total_slots={2} reveal?={@show_hand} />
    </div>
    """
  end

  attr(:community_cards, :list, required: true)

  def community_cards(assigns) do
    ~H"""
    <div class="space-y-2">
      <div class="text-center font-medium">Community Cards</div>
      <.card_row cards={@community_cards} total_slots={5} reveal?={true} />
    </div>
    """
  end

  attr(:cards, :list, required: true)
  attr(:total_slots, :integer, required: true)
  attr(:reveal?, :boolean, required: true)

  def card_row(assigns) do
    ~H"""
    <div class="flex flex-wrap justify-center gap-4 min-h-[7rem] md:min-h-[8rem]">
      <%= for card <- fill_cards(@cards, @total_slots) do %>
        <div class="flex flex-col items-center">
          <%= cond do %>
            <% card && @reveal? -> %>
              <img
                src={card_image_url(card)}
                alt={card_to_string(card)}
                class="w-20 h-28 border rounded shadow transition"
              />
              <div class="mt-1 text-sm text-center">
                {card_to_string(card)}
              </div>
            <% true -> %>
              <img
                src={card_back()}
                alt="Hidden card"
                class="w-20 h-28 border rounded shadow transition"
              />
          <% end %>
        </div>
      <% end %>
    </div>
    """
  end

  attr(:action_state, :atom, required: true)
  attr(:player_chips, :integer, required: true)
  attr(:bet_amount, :integer, required: false, default: 0)

  def player_actions(assigns) do
    ~H"""
    <%= if @action_state == :your_turn  do %>
      <.player_controls player_chips={@player_chips} bet_amount={@bet_amount} disabled={false} />
    <% else %>
      <.player_controls player_chips={@player_chips} bet_amount={@bet_amount} disabled={true} />
    <% end %>
    """
  end

  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">
      <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="button"
          phx-click="player-bet"
          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>
    </div>
    """
  end

  defp button_class(base, true) do
    "#{base} opacity-50 cursor-not-allowed pointer-events-none rounded-lg px-4 py-2 text-white font-medium shadow"
  end

  defp button_class(base, false) do
    "#{base} rounded-lg px-4 py-2 text-white font-medium shadow transition"
  end

  defp format_phase(phase) do
    phase
    |> Atom.to_string()
    |> String.replace("_", " ")
    |> String.split()
    |> Enum.map_join(" ", &String.capitalize/1)
  end

  defp player_label(nil), do: "—"
  defp player_label(player_id), do: "Player #{player_id}"

  defp card_image_url({rank, suit}) do
    "/images/cards/#{suit}_#{rank}.png"
  end

  defp card_to_string({rank, suit}), do: "#{rank} of #{String.capitalize(suit)}"

  defp card_back(), do: "/images/logo.svg"

  defp fill_cards(cards, total_slots) do
    cards
    |> Enum.take(total_slots)
    |> then(fn trimmed ->
      trimmed ++ List.duplicate(nil, total_slots - length(trimmed))
    end)
  end
end

Along with the above code I needed to add a way to get a Player from the viewer_id so I added in this function set to the Room

  def get_player_by_viewer_id(pid, viewer_id) do
    GenServer.call(pid, {:get_player_by_viewer_id, viewer_id})
  end

  @impl true
  def handle_call({:get_player_by_viewer_id, viewer_id}, _from, %__MODULE__{} = state) do
    player =
      state.players
      |> Map.values()
      |> Enum.find(fn player -> player.viewer_id == viewer_id end)

    {:reply, player, state}
  end

That was a decent amount of work so I’ll call it there next time we need to add in all the functions for dealing with all the actions and then start to work on PubSub to keep every subscriber updated on any changes to the Room while we move from action to action.