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.
book6-capstone.livemd β the complete game, layer by layer, runnable end to end. Build along, or build yours first and compare.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.
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:
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.
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
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.)The notebook version runs in Phoenix Playground. Making it real:
| Step | What |
|---|---|
| 1. Generate | mix phx.new bingo --no-ecto (add Ecto later if you persist games) |
| 2. Transplant | Game β lib/bingo/game.ex; GameServer, Registry, DynamicSupervisor β lib/bingo/ + children in application.ex; LiveView β lib/bingo_web/live/ + live "/game/:room" route |
| 3. Test | Core: plain ExUnit, no processes. Engine: start a server per test (start_supervised!). UI: Phoenix.LiveViewTest drives clicks without a browser |
| 4. Ship | mix 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. Scale | Multiple nodes? libcluster + distributed Erlang: PubSub broadcasts across nodes automatically; swap Registry for Horde if rooms must be cluster-global |
Capstone completion checklist (progress saves locally):
Where to go deeper (the sources this series was built from, roughly in order):
| Resource | Why |
|---|---|
| Official Getting Started + Mix & OTP guides | Canonical; 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 OTP | The layer-cake methodology from this capstone, at length |
| LiveView docs + Programming Phoenix LiveView | Streams, uploads, JS hooks, testing β beyond Book 5 |
| Elixir School + Exercism Elixir track | Free 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 |
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.