# Book 6 — Capstone: Buzzword Bingo, a Real-Time System

```elixir
Mix.install([
  {:kino, "~> 0.14"},
  {:phoenix_playground, "~> 0.1"}
])
```

## The plan

Read `book6-capstone.html` for the architecture. We build bottom-up, in three layers, each testable before the next exists:

1. **Core** — `Game`: pure functions, no processes
2. **Runtime** — `GameServer` + Registry + DynamicSupervisor: one process per room
3. **Boundary** — `BingoLive`: LiveView UI with PubSub sync

## Layer 1: the pure core

```elixir
defmodule Game do
  @size 4
  @buzzwords ~w(synergy pivot bandwidth leverage alignment roadmap
    stakeholder deliverable circle-back deep-dive low-hanging-fruit
    move-the-needle paradigm touch-base offline granular
    holistic ideate scalable disrupt north-star runway unpack ping)

  defstruct [:squares, :marks, :winner, moves: 0]

  def new(seed \\ nil) do
    if seed, do: :rand.seed(:exsss, {seed, seed, seed})

    %Game{
      squares: @buzzwords |> Enum.shuffle() |> Enum.take(@size * @size),
      marks: MapSet.new(),
      winner: nil
    }
  end

  def mark(%Game{winner: w}, _player, _i) when not is_nil(w), do: {:error, :game_over}
  def mark(%Game{}, _player, i) when i not in 0..15, do: {:error, :out_of_bounds}

  def mark(%Game{} = game, player, i) do
    game = %{game | marks: MapSet.put(game.marks, i), moves: game.moves + 1}
    game = %{game | winner: if(won?(game.marks), do: player, else: nil)}
    {:ok, game}
  end

  defp won?(marks) do
    rows = for r <- 0..3, do: for(c <- 0..3, do: r * 4 + c)
    cols = for c <- 0..3, do: for(r <- 0..3, do: r * 4 + c)
    diags = [[0, 5, 10, 15], [3, 6, 9, 12]]

    Enum.any?(rows ++ cols ++ diags, fn line ->
      Enum.all?(line, &MapSet.member?(marks, &1))
    end)
  end
end

# Pure = instantly testable, no setup:
game = Game.new(42)
{:ok, game} = Game.mark(game, "ada", 0)
{:ok, game} = Game.mark(game, "ada", 1)
{:ok, game} = Game.mark(game, "ada", 2)
{:ok, game} = Game.mark(game, "grace", 3)   # grace completes the top row!

{game.winner, game.moves, Game.mark(game, "ada", 4)}
```

```elixir
# "Property test" by hand: random games always terminate with a winner in ≤16 marks:
1..100
|> Enum.map(fn seed ->
  Enum.reduce_while(Enum.shuffle(0..15), Game.new(seed), fn i, g ->
    {:ok, g} = Game.mark(g, "p", i)
    if g.winner, do: {:halt, g.moves}, else: {:cont, g}
  end)
end)
|> Enum.frequencies()
|> Enum.sort()
```

### Exercise 6.1

Add a **four-corners** win condition (`[0, 3, 12, 15]`) to `won?/1` and re-run the cells. This is the payoff of a pure core: one line, verified in milliseconds, no server restarts.

## Layer 2: the OTP runtime

```elixir
defmodule GameServer do
  use GenServer, restart: :transient

  @idle_timeout :timer.minutes(30)

  # -- client API --------------------------------------------------------
  defp via(room), do: {:via, Registry, {GameRegistry, room}}

  def start_link(room), do: GenServer.start_link(__MODULE__, room, name: via(room))
  def get_game(room), do: GenServer.call(via(room), :get_game)
  def mark(room, player, i), do: GenServer.call(via(room), {:mark, player, i})
  def new_round(room), do: GenServer.call(via(room), :new_round)

  # -- callbacks ---------------------------------------------------------
  @impl true
  def init(room) do
    IO.puts("room #{room} starting: #{inspect(self())}")
    {:ok, %{room: room, game: Game.new()}, @idle_timeout}
  end

  @impl true
  def handle_call(:get_game, _from, state), do: {:reply, state.game, state, @idle_timeout}

  def handle_call({:mark, player, i}, _from, state) do
    case Game.mark(state.game, player, i) do
      {:ok, game} ->
        broadcast(state.room, {:game_updated, game})
        {:reply, :ok, %{state | game: game}, @idle_timeout}

      {:error, _} = err ->
        {:reply, err, state, @idle_timeout}
    end
  end

  def handle_call(:new_round, _from, state) do
    game = Game.new()
    broadcast(state.room, {:game_updated, game})
    {:reply, :ok, %{state | game: game}, @idle_timeout}
  end

  # idle for @idle_timeout → exit normally (transient: no restart)
  @impl true
  def handle_info(:timeout, state) do
    IO.puts("room #{state.room} idle — shutting down")
    {:stop, :normal, state}
  end

  defp broadcast(room, msg) do
    Phoenix.PubSub.broadcast(PhoenixPlayground.PubSub, "game:" <> room, msg)
  end
end

defmodule GameSystem do
  @moduledoc "Find-or-start rooms — the Book 3 pattern."
  def ensure_room(room) do
    case Registry.lookup(GameRegistry, room) do
      [{pid, _}] -> {:ok, pid}
      [] -> DynamicSupervisor.start_child(GameSupervisor, {GameServer, room})
    end
  end
end

# Infrastructure (in a real app: children of your Application supervisor):
_ = Registry.start_link(keys: :unique, name: GameRegistry)
_ = DynamicSupervisor.start_link(strategy: :one_for_one, name: GameSupervisor)

:ok
```

```elixir
# Exercise the engine with no UI at all — this is how you'd write ExUnit tests:
GameSystem.ensure_room("standup")
GameSystem.ensure_room("all-hands")

GameServer.mark("standup", "ada", 0)
GameServer.mark("standup", "ada", 5)
GameServer.mark("all-hands", "grace", 7)

%{
  standup_marks: GameServer.get_game("standup").marks |> MapSet.to_list(),
  all_hands_marks: GameServer.get_game("all-hands").marks |> MapSet.to_list(),
  rooms_running: Registry.count(GameRegistry)
}
```

```elixir
# Failure isolation check: kill one room, the other is untouched, and the
# supervisor restarts the dead one (fresh game — transient restarts on crashes):
[{standup_pid, _}] = Registry.lookup(GameRegistry, "standup")
Process.exit(standup_pid, :kill)
Process.sleep(100)

%{
  standup_restarted_fresh: GameServer.get_game("standup").marks |> MapSet.size(),
  all_hands_untouched: GameServer.get_game("all-hands").marks |> MapSet.size()
}
```

## Layer 3: the LiveView boundary

```elixir
defmodule BingoLive do
  use Phoenix.LiveView

  def mount(params, _session, socket) do
    room = params["room"] || "lobby"

    if connected?(socket) do
      GameSystem.ensure_room(room)
      Phoenix.PubSub.subscribe(PhoenixPlayground.PubSub, "game:" <> room)
    end

    game =
      case Registry.lookup(GameRegistry, room) do
        [_ | _] -> GameServer.get_game(room)
        [] -> Game.new()
      end

    player = "player-" <> String.slice(inspect(self()), -5, 4)
    {:ok, assign(socket, room: room, game: game, player: player)}
  end

  # Events go TO the engine; state comes back via broadcast (single data path)
  def handle_event("mark", %{"i" => i}, socket) do
    GameServer.mark(socket.assigns.room, socket.assigns.player, String.to_integer(i))
    {:noreply, socket}
  end

  def handle_event("new-round", _params, socket) do
    GameServer.new_round(socket.assigns.room)
    {:noreply, socket}
  end

  def handle_info({:game_updated, game}, socket) do
    {:noreply, assign(socket, game: game)}
  end

  def render(assigns) do
    ~H"""
    <div style="font-family: sans-serif; max-width: 560px; margin: 2rem auto; text-align:center;">
      <h2>🎯 Buzzword Bingo — room: {@room}</h2>
      <p style="color:#888">you are {@player} · <a href="/?room=team-sync">try another room</a></p>

      <h2 :if={@game.winner} style="color:#2a2;">
        🏆 BINGO! {@game.winner} wins!
        <button phx-click="new-round" style="font-size:1rem; margin-left:1rem;">new round</button>
      </h2>

      <div style="display:grid; grid-template-columns:repeat(4, 1fr); gap:6px; margin-top:1rem;">
        <button
          :for={{word, i} <- Enum.with_index(@game.squares)}
          phx-click="mark"
          phx-value-i={i}
          disabled={not is_nil(@game.winner)}
          style={"padding:1rem 0.3rem; border-radius:8px; font-size:0.75rem; cursor:pointer;
                  border:1px solid #ccc;
                  background:#{if MapSet.member?(@game.marks, i), do: "#7e57c2", else: "#f5f5f5"};
                  color:#{if MapSet.member?(@game.marks, i), do: "white", else: "#333"};"}
        >
          {word}
        </button>
      </div>

      <p style="color:#888; margin-top:1.5rem;">moves: {@game.moves} —
      open this page in multiple tabs; everyone marks the same shared card in real time.</p>
    </div>
    """
  end
end

PhoenixPlayground.start(live: BingoLive, port: 5001, open_browser: false)
```

**Open http://localhost:5001 in two or three tabs** and click squares. Everything you've learned is on screen:

* each tab = one LiveView process (Book 5) …
* … talking to one shared `GameServer` per room (Books 2–3) …
* … which serializes moves (no races), runs pure `Game` logic (Book 1's functional core) …
* … and broadcasts via PubSub so every tab's `handle_info` re-renders a diff (Books 4–5).

Try `http://localhost:5001/?room=team-sync` in another tab — an isolated room with its own process, spawned on demand.

## Chaos engineering, capstone edition

```elixir
# Mid-game, murder the room's GenServer while browser tabs are connected:
[{pid, _}] = Registry.lookup(GameRegistry, "lobby")
Process.exit(pid, :kill)
"now click a square in the browser — what happens?"
```

What you should observe: the DynamicSupervisor restarts the room (`restart: :transient` restarts abnormal exits), the next click just works against a fresh game, and no browser tab crashed or reconnected — the LiveViews never died, only the engine did. Failure domains: separated. This, end to end, is why you learned OTP.

### Exercise 6.2

Players' marks are anonymous. Track *who* marked each square: change `marks` from a `MapSet` to a map of `index => player`, update `won?/1` and the template (show initials, give each player a color). Notice how the change ripples: core first, then... actually, just the core and the template — the runtime layer doesn't care. That's the layer cake working.

### Exercise 6.3 (big one)

Add a **lobby LiveView** listing active rooms (`Registry.select/2` or keep a room list in an Agent) with player counts (hint: `Phoenix.PubSub` + a presence counter per room, or count subscribers). This is real system design — take an hour, use the books.

## Ship it

To turn this into a deployable app, follow §5 of `book6-capstone.html`: `mix phx.new bingo --no-ecto`, transplant the three layers, add tests, `mix release`. You'll find the transplant is mostly copy-paste — nothing in this notebook was notebook-specific except `PhoenixPlayground.start`.

**Congratulations — you've completed the series.** 🎓💜
