Elixir GenServer Tutorial: Build a Real-Time Chess Clock with OTP
I wanted to learn Elixir GenServers from scratch, and tutorials that just explain concepts in the abstract don't really stick for me. So I built something real: a multi-room chess clock where each room is a live server process. This is that tutorial.
By the end you'll understand GenServer state, handle_call, handle_cast, handle_info, Registry, and DynamicSupervisor — the core of OTP concurrent applications in Elixir.
What We're Building
A chess clock app where:
- Each room is a live GenServer process managing a two-player countdown timer, just like in chess tournaments — but on the web, with Elixir
- A Phoenix LiveView UI shows the clock and lets players control it in real time
- Rooms are spawned on demand and supervised automatically
Prerequisites: Elixir installed (1.14+), basic familiarity with Elixir syntax.
Step 1: Phoenix Project Setup
We'll create a new Phoenix project. --no-ecto skips the database layer — we don't need it.
mix phx.new chess_clock --no-ecto
cd chess_clock
mix deps.get
mix phx.server
Visit http://localhost:4000 — Phoenix leaves you with a placeholder page to confirm everything is wired up.
Understanding the Generated Structure
lib/
chess_clock/ ← business logic (GenServers, game rules)
chess_clock_web/ ← web layer (LiveViews, controllers)
chess_clock.ex ← application entry point + supervision tree
chess_clock_web.ex ← web helpers
The key mental model: chess_clock/ does not know the web exists. All GenServers and game logic live there. The web layer only calls into them. This separation is what makes Elixir applications easy to test and reason about — and it'll save you pain later.
Open lib/chess_clock.ex and find def start(_type, _args). This is the application callback — it runs on boot and starts your supervision tree. Everything we build will be added there.
Step 2: Your First Elixir GenServer
In Elixir, everything is a process. A GenServer is a process that:
- Holds state — any Elixir term, persisted across messages
- Responds to messages sent by other processes
- Runs concurrently with every other process in the system
Before building the full chess clock, let's build the primitive: a basic counter clock.
We need a minimal API: make it tick, and get the current time. For state, a map with a seconds key.
clock.ex

Sync vs Async: handle_call vs handle_cast
handle_call |
handle_cast |
|
|---|---|---|
| Caller blocks? | Yes — waits for reply | No — fire and forget |
| Returns | {:reply, value, state} |
{:noreply, state} |
| Use when | You need a value back | You just want to trigger an action |
Pattern Matching in Function Heads
Notice %{seconds: seconds} = state in the function signature. This is pattern matching in the function head — the idiomatic Elixir way to destructure state without a case or = inside the body.
The %{state | seconds: seconds + 1} syntax returns a new map with only that key changed. All Elixir data is immutable — you're not mutating anything, just producing a new state value.
Test it in iex -S mix:
{:ok, pid} = ChessClock.Clock.start_link([])
ChessClock.Clock.tick(pid)
ChessClock.Clock.tick(pid)
ChessClock.Clock.get_time(pid) # => 2
Process.alive?(pid) # => true
That pid is the address of a live process. It IS the GenServer. Hold on to that mental model.
Step 3: A Self-Ticking Clock with handle_info
handle_cast and handle_call handle messages sent through the GenServer API. But processes can also receive raw messages — sent with send/2 or Process.send_after/3. These land in handle_info/2.
This is how you build a self-driving timer: the process sends itself a message every second, forever.

Process.send_after(self(), :internal_tick, 1000) schedules :internal_tick into the process's own mailbox after 1000ms. Inside handle_info, we reschedule immediately — creating a self-sustaining loop.
Why not a recursive loop? A GenServer processes one message at a time. A loop inside init would block the process forever — no handle_call, no handle_cast, nothing else runs. Process.send_after returns immediately and the process stays free between ticks. The loop lives in the mailbox, not the call stack.
Step 4: Real Chess Clock State
Now we evolve the clock into an actual two-player chess clock.
Chess clock rules:
- Two players each have their own countdown timer
- Only the active player's clock runs
- A player presses the clock to stop their timer and start their opponent's
- The clock starts paused
Our new state:
%{
players: %{white: 300, black: 300},
active: :white,
running: false
}
Two handle_info clauses handle the paused/running distinction cleanly:
def handle_info(:internal_tick, %{running: false} = state) do
Process.send_after(self(), :internal_tick, 1000)
{:noreply, state}
end
def handle_info(:internal_tick, %{running: true, active: active} = state) do
updated_players = update_in(state, [:players, active], &(&1 - 1))
Process.send_after(self(), :internal_tick, 1000)
{:noreply, updated_players}
end

Elixir tries clauses top to bottom and picks the first match — making multi-clause functions a clean alternative to nested if/else for state-based branching. When the clock is paused, the first clause matches and nothing changes. When it's running, the second clause fires and decrements the active player's time.
update_in/3 traverses nested maps by path. The active variable (:white or :black) works as a dynamic key in the path list — no need to branch on which player is active.
Step 5: Registry — Named Processes
Right now you pass pid everywhere. If a pid is lost, the process is unreachable — in a multi-room app, that's a real problem. PIDs are also meaningless to look at; you can't ask the system "give me the clock for room1" without storing that mapping yourself.
Registry maps names to PIDs. Register a process when it starts, look it up by name instead of carrying the pid around.
Add the Registry to your supervision tree in lib/chess_clock/application.ex:

The key change:
{Registry, keys: :unique, name: ChessClock.Registry}
Now update Clock to register itself on start and expose a name-based API:

The changes that matter:
def start_link({name, seconds}) do
GenServer.start_link(__MODULE__, seconds, name: via(name))
end
def press(name), do: GenServer.cast(via(name), :switch)
def get_state(name), do: GenServer.call(via(name), :get_state)
defp via(name), do: {:via, Registry, {ChessClock.Registry, name}}
The {:via, Registry, {ChessClock.Registry, name}} tuple tells GenServer: "use the Registry module to resolve this name to a pid." Registry implements whereis_name/1 — GenServer calls it automatically before sending any message. You get name-based routing for free.
ChessClock.Clock.start_link({"room1", 30})
ChessClock.Clock.start_link({"room2", 60})
ChessClock.Clock.press("room1") # no pid needed
ChessClock.Clock.get_state("room2") # independent clock, untouched
This is where Elixir processes start to feel like a superpower.
Step 6: DynamicSupervisor — Spawning Rooms on Demand
A static supervision tree starts fixed children on boot. But rooms are created by users at runtime — you need supervised children that you can add dynamically, not just at startup.
DynamicSupervisor is a supervisor where you start children at runtime. Crashed children restart automatically without touching anything else.
Add it to application.ex:
{DynamicSupervisor, name: ChessClock.RoomSupervisor, strategy: :one_for_one}
Create lib/chess_clock/room_manager.ex:

You'll notice this line and wonder what dark magic it is:
[{{:"$1", :_, :_}, [], [:"$1"]}]
It's an Erlang match spec — a mini query language for ETS tables. Breaking it down:
{:"$1", :_, :_}— match pattern: capture the 1st element as$1, ignore the rest[]— no guard conditions[:"$1"]— return just the captured$1
Registry stores entries as {key, pid, value}. This query selects all registered keys — effectively listing every room name. Not pretty, but it's the standard way.
DynamicSupervisor.start_child/2 spawns a new supervised Clock process. If it crashes, the supervisor restarts it without affecting any other room.
ChessClock.RoomManager.create_room("room1", 300)
ChessClock.RoomManager.create_room("room2", 600)
ChessClock.RoomManager.list_rooms() # => ["room1", "room2"]
ChessClock.Clock.press("room1")
ChessClock.Clock.get_state("room1")
What Happens When a Process Crashes?
[{pid, _}] = Registry.lookup(ChessClock.Registry, "room1")
Process.exit(pid, :kill)
# DynamicSupervisor restarts the Clock automatically
ChessClock.RoomManager.list_rooms() # => ["room1", "room2"] — still there
This is the OTP promise: let it crash. Supervisors handle recovery. Your business logic doesn't need defensive error handling for process failures. Coming from most other languages, this feels wrong at first — you've been trained to defensively wrap everything. Give it time. It grows on you.
At this point the GenServer, Registry, and DynamicSupervisor are all wired up — the backend is done. In the next part we connect it to the browser using Phoenix.PubSub and LiveView. Continue reading →