Book 6 β€” Capstone: Build a Real-Time System

Everything converges here. You'll build Buzzword Bingo β€” a multiplayer real-time game β€” from a blank page: a pure functional core, a supervised OTP engine (one process per game room), and a LiveView UI where every player sees every move instantly. The notebook contains the full working build; this book is the architectural walkthrough and your map for doing it solo.

πŸ““ Companion notebook: book6-capstone.livemd β€” the complete game, layer by layer, runnable end to end. Build along, or build yours first and compare.

1. The spec

Players join a named room and get a 4Γ—4 card of buzzwords. Any player can mark a square when the buzzword is uttered in their meeting. First player to complete a row, column, or diagonal wins β€” every connected player sees the win the moment it happens. Rooms are independent: a crash in one must never touch another. Idle rooms should clean themselves up.

Generic shape: many independent stateful entities + multiple concurrent users per entity + real-time fan-out. Swap "game room" for document, auction, shopping cart, chat channel, IoT device, or support ticket and the architecture is identical β€” this is the shape of most real-time systems.

2. Design first: the layer cake

The single most useful Elixir design discipline (from Designing Elixir Systems with OTP): do the work in pure functions; add processes only for runtime concerns (state over time, concurrency, failure isolation). Three layers:

BOUNDARY β€” LiveView (BingoLive) translates clicks ⇄ engine calls ⇄ PubSub; holds NO game logic RUNTIME β€” OTP (GameServer + Registry + DynamicSupervisor) one GenServer per room; owns a Game struct; broadcasts changes; idle timeout CORE β€” pure functions (Game module) new/1, mark/3, winner?/1 β€” no processes, no side effects, trivially testable
Why bottom-up pays: the core is testable without starting anything (property tests run thousands of games in milliseconds). The runtime layer is thin β€” mostly plumbing you've written three times in this series. And the UI can't corrupt game state because it never touches it directly β€” it can only send messages. Compare with the typical JS app where UI, state, and transport interleave freely.

3. The engine (Books 1–3 applied)

Core: a Game struct + pure transitions. Every function takes a game, returns a game (or an error tuple):

defmodule Game do
  defstruct [:squares, :marks, :winner, players: []]

  def new(words), do: ...                       # build the 4Γ—4 card
  def mark(game, player, index), do: ...        # returns {:ok, game} | {:error, :already_won}
  def winner?(game), do: ...                    # rows/cols/diags check β€” pure
end

Runtime: the process-per-entity pattern, verbatim from Book 3:

defmodule GameServer do
  use GenServer, restart: :transient          # don't restart normal exits (idle shutdown)

  def start_link(room), do: GenServer.start_link(__MODULE__, room, name: via(room))
  defp via(room), do: {:via, Registry, {GameRegistry, room}}

  def handle_call({:mark, player, i}, _from, %{game: game} = state) do
    case Game.mark(game, player, i) do
      {:ok, game} ->
        broadcast(state.room, {:game_updated, game})   # PubSub β†’ every player's LiveView
        {:reply, :ok, %{state | game: game}, @idle_timeout}
      {:error, _} = err -> {:reply, err, state, @idle_timeout}
    end
  end

  def handle_info(:timeout, state), do: {:stop, :normal, state}   # idle room exits cleanly
end

Design decisions worth noticing: call (not cast) for marks β€” the player should know their move landed, and it back-pressures spam-clicking. The GenServer timeout (the extra element in reply tuples) gives idle cleanup for free. restart: :transient means crashes restart the room, but idle exits don't resurrect it. Broadcasts happen in the server, not the UI β€” the engine is the single writer, so every observer sees identical state.

QUIZ 6.1

Two players click the same square at the same instant. What guarantees there's no race?
The room's GenServer is the serialization point (Book 2: one message at a time). The second mark is applied to the state left by the first β€” no locks, no transactions, no race. Choosing WHERE that single writer lives is the key decision in most BEAM designs.

4. The UI (Books 4–5 applied)

The LiveView is deliberately dumb: translate events into engine calls, translate broadcasts into assigns:

def mount(%{"room" => room}, _session, socket) do
  if connected?(socket) do
    GameSystem.ensure_room(room)                 # find-or-start (Book 3)
    Phoenix.PubSub.subscribe(MyApp.PubSub, "game:" <> room)
  end
  {:ok, assign(socket, room: room, game: GameSystem.get_game(room))}
end

def handle_event("mark", %{"i" => i}, socket) do
  GameSystem.mark(socket.assigns.room, socket.assigns.player, String.to_integer(i))
  {:noreply, socket}                               # note: no assign here!
end

def handle_info({:game_updated, game}, socket) do
  {:noreply, assign(socket, game: game)}           # ALL updates come through broadcast
end
Subtle but important: handle_event doesn't update assigns. The click goes to the engine; the engine broadcasts; the update comes back through handle_info β€” for everyone, including the clicker. One data flow path instead of two means the clicker can't see different state than spectators. (If the round-trip felt slow you'd add optimistic assigns β€” but measure first; on the BEAM this round-trip is sub-millisecond.)

5. Taking it to production

The notebook version runs in Phoenix Playground. Making it real:

StepWhat
1. Generatemix phx.new bingo --no-ecto (add Ecto later if you persist games)
2. TransplantGame β†’ lib/bingo/game.ex; GameServer, Registry, DynamicSupervisor β†’ lib/bingo/ + children in application.ex; LiveView β†’ lib/bingo_web/live/ + live "/game/:room" route
3. TestCore: plain ExUnit, no processes. Engine: start a server per test (start_supervised!). UI: Phoenix.LiveViewTest drives clicks without a browser
4. Shipmix release (Book 3); set SECRET_KEY_BASE, PHX_HOST via runtime.exs; deploy anywhere a binary runs (Fly.io is the community favorite for BEAM apps)
5. ScaleMultiple nodes? libcluster + distributed Erlang: PubSub broadcasts across nodes automatically; swap Registry for Horde if rooms must be cluster-global

QUIZ 6.2

Your bingo node handles 10k concurrent rooms fine, but you deploy 3 nodes behind a load balancer and players in the same room sometimes see different games. Why?
Registry is node-local. Two nodes can each spawn "room:standup" with divergent state. Fixes: route by room to a consistent node, use a cluster-wide registry (Horde/Swarm/:global), or make state external. PubSub, by contrast, IS cluster-aware out of the box once nodes are connected.

6. Your map from here

Capstone completion checklist (progress saves locally):

Where to go deeper (the sources this series was built from, roughly in order):

ResourceWhy
Official Getting Started + Mix & OTP guidesCanonical; the Mix & OTP guide builds a KV store like our Book 3
Elixir in Action (JuriΔ‡)The best deep treatment of exactly Books 1–3
Designing Elixir Systems with OTPThe layer-cake methodology from this capstone, at length
LiveView docs + Programming Phoenix LiveViewStreams, uploads, JS hooks, testing β€” beyond Book 5
Elixir School + Exercism Elixir trackFree drills to make the syntax automatic
Ecosystem: Oban (jobs), Broadway (data pipelines), Nx/Axon (ML), Presence (who's online)Each is a thin layer over the OTP you now know

The end (of the beginning)

Six books ago, = didn't mean what you thought it meant. Now you can design a system where a million users each get their own supervised process and a crash is a non-event. The mental model you built β€” immutable data, message-passing processes, supervision as architecture, UI as process state β€” is the whole thing. Everything else in the ecosystem is a library away.