A distributed chat system built to teach GenServers, OTP, and distributed Elixir.
The file lib/beam_chat/server.ex contains a skeleton with all the function
signatures, types, and detailed TODO comments — but no implementation.
Your task: implement the GenServer callbacks and helper functions so that the tests pass and the chat system works end-to-end.
When you're done, you can check your work against the solution branch:
git diff main..solution -- lib/beam_chat/server.ex Terminal 1 (Server Node) Terminal 2 (Client Node) Terminal 3 (Client Node)
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐
│ │ │ BeamChat.TUI │ │ BeamChat.TUI │
│ BeamChat.Server │◄────────│ BeamChat.Client │ │ BeamChat.Client │
│ (GenServer) │────────►│ (GenServer) │ │ (GenServer) │
│ │ │ │ │ │
│ - tracks clients │◄────────┤ │ │ │
│ - broadcasts msgs │────────►│ │ │ │
│ - keeps history │ └─────────────────────┘ └─────────────────────┘
│ │ ▲ │
│ │◄───────────────────────────────────────────────────┘ │
│ │─────────────────────────────────────────────────────►│
└─────────────────────┘
Before writing anything, read these files to understand how the system works:
lib/beam_chat/server.ex— Read the Client API functions (already done). They tell you exactly what messages each callback will receive.lib/beam_chat/client.ex— See how the client joins, sends messages, and handles incoming{:new_message, msg}tuples.test/beam_chat_test.exs— The tests define the expected behavior.
Start with the private functions at the bottom of server.ex:
build_message/2— Build a%{user: ..., text: ..., timestamp: ...}mapfind_client/2— Search a MapSet for a tuple matching a PIDalready_joined?/2— Check if a PID is in the clients MapSetbroadcast/2— Send a message to every client PID
Work through these in order:
init/1— Set up the initial statehandle_call(:get_history, ...)— Return the history (easiest callback)handle_call({:join, username}, ...)— Join logic with monitoringhandle_cast({:send_message, ...}, ...)— Message relayhandle_cast({:leave, ...}, ...)— Graceful disconnecthandle_info({:DOWN, ...}, ...)— Crash detection
mix testAll 6 tests should pass.
# Terminal 1 — Server
./start_server.sh
# Terminal 2 — Alice
./start_client.sh Alice
# Terminal 3 — Bob
./start_client.sh Bob| Callback | Triggered by | Returns | Use when |
|---|---|---|---|
handle_call |
GenServer.call/2 |
{:reply, value, state} |
You need a response |
handle_cast |
GenServer.cast/2 |
{:noreply, state} |
Fire-and-forget |
handle_info |
send/2 or system |
{:noreply, state} |
External/unexpected messages |
ref = Process.monitor(pid) # Start watching a process
Process.demonitor(ref, [:flush]) # Stop watching
# If pid dies -> you receive {:DOWN, ref, :process, pid, reason}| File | Status | What it teaches |
|---|---|---|
lib/beam_chat/server.ex |
TODO | Core GenServer: state, call, cast, info, monitors |
lib/beam_chat/client.ex |
Done | GenServer as a client: connecting, receiving pushes |
lib/beam_chat/tui.ex |
Done | I/O, ANSI codes, separating UI from logic |
lib/beam_chat/application.ex |
Done | OTP Application, supervision trees |
lib/beam_chat.ex |
Done | Entry point, distributed node connection |
test/beam_chat_test.exs |
Done | Testing GenServers with spawn + assert_receive |
Once you finish the server, try these extensions:
- Add
/userscommand — Query the server for the list of connected usernames - Add private messages — Implement
/msg Bob Hey there!to send only to Bob - Add a message limit — Only keep the last 100 messages in history
- Handle server crash — What happens to clients when the server dies? Add reconnect logic