Phoenix PubSub and LiveView Tutorial: Real-Time Chess Clock (Part 2)
This is part 2 of building a real-time chess clock in Elixir. Part 1 built the backend: a GenServer managing clock state, Registry for named processes, and DynamicSupervisor for spawning rooms on demand.
The backend works — but it's invisible to the browser. That's what this part fixes. We'll wire up Phoenix.PubSub to broadcast state changes and Phoenix LiveView to reflect them in the UI instantly, across all connected clients, with no client-side JavaScript.
Phoenix.PubSub — Broadcasting GenServer State to the Browser
The Clock GenServer lives on the server and has no idea a browser exists. That's good separation of concerns. But the browser needs to know when state changes, and polling is not the answer, we have to be event-oriented.
Phoenix.PubSub is the bridge. It's a topic-based broadcast system built into every Phoenix application: any process can publish to a topic, and any subscribed process receives the message. In practice: the Clock publishes after every state change, and the LiveView receives it and re-renders. I come from an IoT world, and when I first encountered this feature, I went crazy.
The pattern has three parts:
- The Clock broadcasts its state after every mutation
- The LiveView subscribes to the room's topic on mount
- Every broadcast triggers a re-render.
Storing the Room Name in Clock State
The Clock needs its own name to broadcast to the right topic. Then lets add name to state in init/1:
def init({name, seconds}) do
state = %{
name: name,
players: %{white: seconds, black: seconds},
active: :white,
running: false
}
Process.send_after(self(), :internal_tick, 1000)
{:ok, state}
end
Previously init received seconds directly. Now it receives {name, seconds} and stores the name — so the Clock always knows where to broadcast.
Broadcasting After Every State Change
I've created a small and private broadcast/1 helper, we'll use it everytime we want to bradcast the state:
defp broadcast(state) do
Phoenix.PubSub.broadcast(
ChessClock.PubSub,
"room:#{state.name}",
{:clock_update, state}
)
end
We'll call broadcast(new_state) at the end of every handle_cast and the running handle_info. Skip it in the running: false tick — state hasn't changed, no point pushing to clients you'd just be waking up for nothing.
Exposing a Subscribe Function
def subscribe(name) do
Phoenix.PubSub.subscribe(ChessClock.PubSub, "room:#{name}")
end
Any process that calls this will receive {:clock_update, state} in its mailbox on every broadcast. LiveView is just a process — it calls this on mount and handles the messages like any other.
Phoenix LiveView — Real-Time UI Without JavaScript
LiveView is a server-rendered, stateful process connected to the browser over a persistent WebSocket. When its assigns change, it computes a diff of the rendered HTML and pushes only the changed parts to the client. So we get real-time UI without writing a single line of JavaScript (more magic...).
Create the room_live.ex module:
defmodule ChessClockWeb.RoomLive do
use ChessClockWeb, :live_view
@impl true
def mount(%{"name" => name}, _session, socket) do
if connected?(socket) do
ChessClock.Clock.subscribe(name)
end
clock = ChessClock.Clock.get_state(name)
{:ok, assign(socket, clock: clock, room: name)}
end
@impl true
def handle_info({:clock_update, clock}, socket) do
{:noreply, assign(socket, clock: clock)}
end
@impl true
def handle_event("press", _params, socket) do
ChessClock.Clock.press(socket.assigns.room)
{:noreply, socket}
end
@impl true
def handle_event("pause", _params, socket) do
ChessClock.Clock.pause(socket.assigns.room)
{:noreply, socket}
end
end
Why connected?(socket)
mount/3 is called twice: once server-side to render the initial HTML for the HTTP response, and again after the WebSocket connects. Without the connected? guard, you'd subscribe twice and receive every broadcast twice. This is one of the most common LiveView gotchas — the guard costs one line and saves real confusion.
The Full Data Flow
This is the path every single update takes, from browser click to DOM change:
Browser click → phx-click → handle_event → Clock.press()
↓
handle_cast in GenServer
↓
broadcast({:clock_update, state})
↓
handle_info in LiveView
↓
assign(socket, clock: new_state)
↓
DOM diff pushed to browser
The browser never talks to the Clock directly, it only talks to LiveView, which talks to the GenServer. LiveView is the only entry point. This keeps the GenServer logic completely decoupled from the web layer — the same separation established in Part 1.
Add the Route
In lib/chess_clock_web/router.ex:
live "/room/:name", RoomLive
Test It
iex -S mix phx.server
ChessClock.RoomManager.create_room("alpha", 300)
Visit http://localhost:4000/room/alpha. Open a second tab at the same URL. Press the clock in one tab — both update instantly. That's PubSub doing its job: one broadcast from the GenServer, all subscribers receive it simultaneously.
Open ten tabs if you want. They all stay in sync. The GenServer doesn't care how many are watching.
What's Next
The clock runs, rooms are supervised, and the UI updates in real time. What's still missing:
- Game over — detecting when a player's time hits zero, stopping the clock, and declaring a winner
- Room lobby — a LiveView listing all active rooms with a form to create new ones
Both coming in Part 3.