Playing Poker with Elixir (pt. 3)

So far, we've written code to rank poker hands and manage the state of a game of poker. In the previous post, however, we took a bit of a shortcut - we skipped accounting. We trusted players to bet any amount, regardless of their total balance, and never awarded the pot to the hand's winner. Let's change that!

As you might expect, we won't be exchanging real money in our poker hands. New players in our system will start with a fixed amount of chips. When joining a table, they'll be able to bring a portion of their bankroll with them. Only the "money" brought to the table will be available for use in hands. At any point in time, a player can bring additional chips to the table or cash out for a portion, with the caveat that doing so doesn't effect a hand in-progress.

To accomplish all this, we'll introduce two new processes.

You can take that to the bank

One of these processes will be the bank. Its responsibility is simple: it allows a player to manage their money within the application as a whole. You might think of it as a cashier in a casino, which would convert cash to chips and back again.

Our bank GenServer will have two APIs - deposit/2 and withdraw/2:

defmodule Poker.Bank do
  use GenServer

  def start_link do
    GenServer.start_link(__MODULE__, [], name: __MODULE__)
  end

  def deposit(player, amount) do
    GenServer.cast(__MODULE__, {:deposit, player, amount})
  end

  def withdraw(player, amount) do
    GenServer.call(__MODULE__, {:withdraw, player, amount})
  end
end

There's one notable difference here: we're registering our bank process by its module name. We can use this name when calling our GenServer instead of its process id. Therefore, clients of our server won't need to know its pid - as long as its running, they can access it. Here's the implementation:

def init(_) do
  {:ok, %{}}
end

def handle_cast({:deposit, player, amount}, state) 
  when amount >= 0 do
  {
    :noreply,
    Map.update(state, player, amount, fn current ->
      current + amount 
    end)
  }
end

def handle_call({:withdraw, player, amount}, _from, state) 
  when amount >= 0 do
  case Map.fetch(state, player) do
    {:ok, current} when current >= amount ->
      {:reply, :ok, Map.put(state, player, current - amount)}
    _ ->
      {:reply, {:error, :insufficient_funds}, state}
  end
end

This code exposes one of the Elixir's strengths around concurrency. Our bank process is simple, straightforward, and avoids a race condition that we might encounter in other languages.

For example, imagine two withdrawals are being processed concurrently. If both withdrawals read from the data store at the same time, they might both see a balance with enough funds available. They'd both process the withdrawal and deduct funds, even if the sum total of their amounts is too much! To avoid this problem, we'd have to be careful to perform the read and write as one atomic operation.

In our implementation, we don't have to think about this problem. All operations within our bank will be handled sequentially, since they execute inside the same process. One withdrawal will follow the other, and the second would observe that not enough funds were available and properly fail.

Putting it all on the table

Let's move on to our second process, the table. It will keep track of which players are seated and their individual balances. When ready, it will kick off a new hand process. There might be many tables running concurrently, and each one should track its players independently, including their balances.

There are two ways a player's balance at a table can change - either by buying in/cashing out or by participating in a hand. We'd like to keep our hand and table process separate, so we're going to need some way for them to coordinate.

For now, let's focus on the table itself and handle the interaction with a hand later. Here's the API:

defmodule Poker.Table do
  use GenServer

  def start_link(num_seats) do
    GenServer.start_link(__MODULE__, num_seats)
  end

  def sit(table, seat) do
    GenServer.call(table, {:sit, seat})
  end

  def leave(table) do
    GenServer.call(table, :leave)
  end

  def buy_in(table, amount) do
    GenServer.call(table, {:buy_in, amount})
  end

  def cash_out(table) do
    GenServer.call(table, :cash_out)
  end
end

First, a player will send the sit message to the table. If the requested seat is available, they can buy_in for a desired amount, as long as they have the funds available in the bank. Later on, they can cash_out, returning their chips to the bank, and leave the table.

Let's implement GenServer's init/1:

def init(num_seats) do
  players = :ets.new(:players, [:protected])

  {:ok, %{hand: nil, players: players, num_seats: num_seats}}
end

We set up a state with a nil hand. We'll keep track of a process id here when we start a hand, so nil here indicates no hand is in progress.

We're using Erlang Term Storage to keep track of the players at our table. ETS tables act as in-memory databases, owned by the process that created it. Rows can be fetched by their key or an arbitrary query can be executed against the table (although a query scans the entire table, since there's no indexing). We'll insert a row into this ETS table for each player, keeping track of their process id, chosen seat, and balance. As always, there's much more to learn about ETS at Learn You Some Erlang.

Here's the code for the sit message:

def handle_call(
  {:sit, seat}, _from, state = %{num_seats: last_seat}
) when seat < 1 or seat > last_seat do
  {:reply, {:error, :seat_unavailable}, state}
end

def handle_call({:sit, seat}, {pid, _ref}) when is_integer(seat) do
  {:reply, seat_player(state, pid, seat), state}
end

defp seat_player(%{players: players}, player, seat) do
  case :ets.match_object(players, {:_, seat, :_}) do
    [] ->
      :ets.insert(players, {player, seat, 0})
      :ok
    _ -> {:error, :seat_taken}
  end
end

The first head uses a guard to make sure the player isn't attempting to sit at an invalid seat. The second delegates to seat_player. We check to see if a player is already seated in the requested position using ETS's match_object/2, which lets us execute a query against the table. The :_ atoms act as wild cards, and we specify the seat we're looking for.

If we get no results, we know it's save to insert into the ETS table. Unlike our other GenServers, we don't have to update our initial state here - the data backing the ETS table lives outside of our process.

The leave operation is the inverse of sit:

def handle_call(:leave, {pid, _ref}, state = %{hand: nil}) do
  case get_player(state, pid) do
    {:ok, %{balance: 0}} ->
      unseat_player(state, pid)
      {:reply, :ok, state}
    {:ok, %{balance: balance}} when balance > 0 ->
      {:reply, {:error, :player_has_balance}, state}
    error -> {:reply, error, state}
  end
end

defp get_player(state, player) do
  case :ets.lookup(state.players, player) do
    [] -> {:error, :not_at_table}
    [tuple] -> {:ok, player_to_map(tuple)}
  end
end

defp unseat_player(state, player) do
  :ets.delete(state.players, player)
end

defp player_to_map({id, seat, balance}), do: 
  %{id: id, seat: seat, balance: balance}

We're matching on a nil hand in the function head. Effectively, this prevents a player from leaving while the hand is in progress, which will simplify our accounting. For similar reasons, we also make sure the player has no balance before they leave.

In ETS, each row of data is a tuple, and the first element is the key. We can find the player by the key using ETS's lookup/2. There's an interesting thing about lookup: it returns a list of results, rather than a single tuple. This is because ETS tables can bags instead of sets, and bags allow for multiple objects with the same key.

Buying in and cashing out

Our table now supports sitting and leaving. Let's look at buy_in:

def handle_call(
  {:buy_in, amount}, {pid, _ref}, state = %{hand: nil}
) when amount > 0 do
  case state |> get_player(pid) |> withdraw_funds(amount) do
    :ok ->
      modify_balance(state, pid, amount)
      {:reply, :ok, state}
    error -> {:reply, error, state}
  end
end

defp withdraw_funds({:ok, %{id: pid}}, amount), do:
  Poker.Bank.withdraw(pid, amount)
defp withdraw_funds(error, _amount), do: error

defp modify_balance(state, player, delta) do
  :ets.update_counter(state.players, player, {3, delta})
end

Again, we're making sure to prevent player from adding to their chip total while the hand is in progress. That wouldn't be very fair!

In our case statement, we'd like to make sure that the player is at the table before we withdraw from their account. This is a specific instance of a more general problem: chaining a series of functions together, but allowing any function within the series to abort on an error. We handle the missing player error with pattern matching in withdraw_funds, effectively skipping the withdrawal.

It's worth noting that Elixir has recently added a new macro, with, that solves this problem at the language level. As of this writing, it's still not available without building elixir from master.

Our modify_balance function uses ETS's update_counter function, which nicely increments the balance we've stored in the table without us having to read it first.

The implementation of cash_out follows from buy_in, and I won't include it here. If you're interested, the full source is available on GitHub.

Monitoring the hand

Our table is now managing players and their balances, but our hand process from last week needs to access this state as well. Before accepting a bet, a hand needs to make sure to deduct from the player's balance appropriately. When the hand finishes, it needs to award its pot to the winner.

To integrate the processes, we'll add two new messages to the Table GenServer. The first one, deal, will start a new hand:

def deal(table) do
  GenServer.call(table, :deal)
end

def handle_call(:deal, _from, state = %{hand: nil}) do
  players = get_players(state) |> Enum.map(&(&1.id))

  case Poker.Hand.start(self, players) do
    {:ok, hand} ->
      Process.monitor(hand)
      {:reply, {:ok, hand}, %{state | hand: hand}}
    error ->
      {:reply, error, state}
  end
end

def handle_call(:deal, _from, state) do
  {:reply, {:error, :hand_in_progress}, state}
end

Our deal message uses patterns to make sure no hand is in progress. If one is, we'll fall through to the second function head and return an error.

The success case starts a hand process, passing the table's process id to it. The hand will send messages to the table to adjust the player balances appropriately. We're also monitoring the hand process. By monitoring the hand process, our table will receive a message when the hand terminates:

def handle_info(
  {:DOWN, _ref, _type, hand, _reason}, state = %{hand: hand}
) do
  {:noreply, %{state | hand: nil}}
end

GenServer's handle_info is called for messages received by our server that aren't calls, casts, or system messages. When our monitor reports the hand is down, we remove it from our state, allowing a new hand to be started again.

Just a note: we're starting the process and monitor in two separate steps. It's possible that the process could terminate in between those two steps. Either way, we'll receive the DOWN message, but in this edge case, we won't know why the process died, just that it's down. For our purposes, it doesn't matter.

Since our hand process now has the table's pid, it can send it messages to manage the players' balances:

def update_balance(table, player, delta) do
  GenServer.call(table, {:update_balance, player, delta})
end

def handle_call(
  {:update_balance, player, delta}, {hand, _}, state = %{hand: hand}
) when delta < 0 do
  case get_player(state, player) do
    {:ok, %{balance: balance}} when balance + delta >= 0 ->
      modify_balance(state, player, delta)
      {:reply, :ok, state}
    {:ok, _} -> {:reply, {:error, :insufficient_funds}, state}
    error -> {:reply, error, state}
  end
end

def handle_call({:update_balance, _, _}, _, state) do
  {:reply, {:error, :invalid_hand}, state}
end

Our single update_balance API will handle debits and credits. Since we're matching the sender of the message with our state's hand, we ensure only our current hand can update player balances.

I've only included the debit case here. It's straightforward, and uses the helper functions we've already defined to access and modify the players ETS table.

Wrapping up

The full implementation of the table module is on GitHub. I've also updated the hand process there to communicate with it.

At the beginning of this series, I promised we'd build a poker web app, but we haven't touched Phoenix yet. We're getting close! Before we get there, there's a major problem we have to deal with: crashes. If our table or hand process would die unexpectedly, we'd lose all of their state, taking the player balances with it. In the next post, we'll add supervision to the mix and make our processes more robust.