Soccer is cultural for us.
We watch games from different places every week and chat about the teams we like, but it takes time to keep track of everything. The good news is many web apps do live coverage of almost every soccer match on the planet. The bad news is great live experiences are complex to build.
From that, we started wondering about the challenges of developing a real-time application and how far we would get if we decided to do that.
The idea came at a good moment because the World Cup had started. Also, we were looking for chances to stress the tools we like to work with the most, Elixir and the Phoenix framework. Smart people we admire say those tools are perfect for real-time apps, so deciding to build one in a domain we like was easy.
Now, after a couple of weeks of working in our free time, we want to present LiveMatch, a real-time app for soccer to follow multiple games in one place. This post is a guide on how we’re building it.
Meet LiveMatch
Before going into details, we will briefly introduce our accomplishments so far.
Live updates
Multiple matches get real-time changes in their scores and time. The timeline of events details the game on a specific page. As soon as a new fact happens, the browser automatically syncs to display the latest information to users.
Distribution
We can scale the app in a breeze by running it in a set of different nodes distributed across an Elixir cluster. Once a new app instance is up and running and connected to our group of nodes, it’s ready to broadcast matches from their current state.
Fault-tolerance
Live matches replicate across multiple instances (nodes) of the app as supervised Elixir processes. If one node goes down, the browser reconnects to another automatically without losing any data. We ensure that if something bad happens, another node in the cluster will be ready to take place.
Boring frontend
HTML and CSS, zero JavaScript frameworks, no TypeScript, no “integration with the backend”, no building tools, reusable components without the drawbacks. Boring is great.
Real-time apps
Real-time apps are about trust. The UX is challenging because users want the latest information available automatically. Manual actions on a live experience will break their expectations. We need support from reliable tools and programming techniques to achieve that level of trust. Fortunately, with the help of Elixir and Phoenix, we can meet those requirements with minimal code.
The Elixir programming language is a functional language for building maintainable and scalable applications. It runs on the Erlang VM, a battle-tested and reliable environment for creating low-latency, distributed, and fault-tolerant systems with requirements on high availability.
Phoenix is the go-to web framework for Elixir. It allows the creation of interactive web applications quickly with less complexity, and it brings the Phoenix LiveView behaviour, which provides real-time experiences with server-rendered HTML. Take a look at this overview from Chris McCord on how it works:
LiveView strips away layers of abstraction, because it solves both the client and server in a single abstraction. HTTP almost entirely falls away. No more REST. No more JSON. No GraphQL APIs, controllers, serializers, or resolvers. You just write HTML templates, and a stateful process synchronizes it with the browser, updating it only when needed. And there’s no JavaScript to write.
Sounds great, right? Nothing pays the price of not thinking about many complicated things at the beginning of a new project.
With all that said, let’s start exploring LiveMatch.
The matchday
Our matchday page shows all the games happening in the current day. They are handled in a LiveView module called
MatchLive.Index
, which groups the matches into three states: live
, soon
, and ended
.
defmodule LiveMatchWeb.MatchLive.Index do
use LiveMatchWeb, :live_view
@impl true
def mount(_params, _session, socket) do
matches = Matches.list_matches(%{period: :today})
{:ok, assign_matches(matches, socket), temporary_assigns: [ended: []]}
end
defp assign_matches(matches, socket) do
assign(socket,
live: matches[:live] || [],
soon: matches[:soon] || [],
ended: matches[:ended] || [],
page_title: page_title()
)
end
end
We can talk about how the life cycle of a LiveView works from that piece of code:
-
The app sends the request to
MatchLive.Index
every time a new user enters the matchday page; -
Then, the module calls its
mount
function. We get our initial data - the list of matches - at that moment; - A corresponding HTML template is rendered and sent to the browser;
# index.html.heex
<div class="Page Page--index">
<section class="Section">
<h2>Now 🔥</h2>
<p :if={@live == []} class="Section__message">Nothing is happening. Come back later.</p>
<div id="live-matches" class="Match-Grid" phx-update="append">
<%= for match <- @live do %>
<.match_card match={match} show_path={Routes.match_show_path(@socket, :show, match)} />
<% end %>
</div>
</section>
<section class="Section">
<h2>Soon ⏲️ </h2>
<p :if={@soon == []} class="Section__message">There's nothing planned for today.</p>
<div id="soon-matches" class="Match-Grid">
<%= for match <- @soon do %>
<.match_card match={match} show_path={Routes.match_show_path(@socket, :show, match)} />
<% end %>
</div>
</section>
<section :if={@ended != []} class="Section">
<h2>Done 🙅🏻♂️</h2>
<div id="ended-matches" class="Match-Grid" phx-update="append">
<%= for match <- @ended do %>
<.match_card match={match} show_path={Routes.match_show_path(@socket, :show, match)} />
<% end %>
</div>
</section>
</div>
- At this point, Phoenix LiveView links the client to the server through a WebSocket connection. A brand new stateful Elixir process is created to handle the communication between the two.
Live updates with PubSub
We need to update the list of matches in the browser every time the following events happen:
- The match time changes;
- The score updates;
- A new match starts or ends.
It’s safe to subscribe the LiveView to events like those when the socket connection between the browser and
the process is ready, which explains the need for the if connected?(socket)
in our code. We are subscribing
to them for each match in our matchday page.
defmodule LiveMatchWeb.MatchLive.Index do
use LiveMatchWeb, :live_view
@impl true
def mount(_params, _session, socket) do
matches = Matches.list_matches(%{period: :today})
if connected?(socket) do
Enum.each(matches, fn match ->
LiveMatch.subscribe("match:#{match.id}:live")
end)
end
{:ok, assign_matches(matches, socket), temporary_assigns: [ended: []]}
end
end
Our handle_info/2
callbacks will run whenever a new message is published to those topics (we will see how to
do that in this section). From the callbacks, we have access to the internal
LiveView state, and any changes in it will sync automatically with the browser.
defmodule LiveMatchWeb.MatchLive.Index do
use LiveMatchWeb, :live_view
@impl true
def handle_info({:live, %Match{live: true} = match}, socket) do
socket =
socket
|> update(:live, &[match | &1])
|> update(:soon, fn matches -> Enum.reject(matches, &(&1.id == match.id)) end)
{:noreply, socket}
end
@impl true
def handle_info({:live, %Match{live: false} = match}, socket) do
socket =
socket
|> update(:live, fn matches -> Enum.reject(matches, &(&1.id == match.id)) end)
|> assign(:ended, [match])
{:noreply, socket}
end
end
That’s basically how the matchday page works. Note that we didn’t touch any HTML or JavaScript to update the page in the browser. The hard work happens behind the scenes. Another cool thing is that the framework will diff the changes to check if the new content is different from what the browser already has, preventing redundant updates, which makes this really fast!
The match details
Besides following matches, times, and results, we sometimes want to follow a specific game’s events in more detail. Things like who scores the goals, substitutions, or if the VAR already screwed anything. For those cases, we have the match details page.
The page is straightforward. It listens to the same events the matchday page does, but only for the selected
match. The significant difference between them is the timeline
, which has a clear responsibility, display
the events happening in the game.
Whenever something happens in the match, the timeline in the browser will update to reflect the new state. The component can display match facts, like goals or red cards, and relevant tweets using the hashtag provided on the page.
defmodule LiveMatchWeb.MatchLive.Show do
use LiveMatchWeb, :live_view
import LiveMatchWeb.MatchLive.Components
@impl true
def mount(%{"id" => id}, _session, socket) do
match = Matches.get_match!(id)
if connected?(socket) do
LiveMatch.subscribe("match:#{match.id}:events")
end
{:ok, assign_match(match, socket), temporary_assigns: [events: []]}
end
@impl true
def handle_info(%Event{} = event, socket) do
socket = assign(socket, :events, [event])
{:noreply, socket}
end
end
# show.html.heex
<article class="Match-Details">
<.match_time match={@match} />
<section class="Match-Timeline">
<header class="Match-Timeline__header">
<h3>Timeline</h3>
<.hashtag match={@match} />
</header>
<section class="Match-Timeline__body">
<div class="Match-Timeline__container">
<p class="Match-Timeline__placeholder" :if={@events == []}>
<.timeline_placeholder />
</p>
<ul class="Match-Timeline__list" id="events" phx-update="prepend">
<%= for event <- @events do %>
<li id={"event-#{event.id}"} class="Match-Timeline__event">
<time><%= LiveMatchWeb.MatchLive.Helpers.format_time(event.time, event.period) %></time>
<p><%= event.text %></p>
</li>
<% end %>
</ul>
</div>
</section>
</section>
</article>
The Match Transmission Engine
It’s time to understand a bit more about one vital part of LiveMatch, its runtime system. We’re referencing to
it as The Match Transmission Engine
. We’re using it only for soccer, but it can be customized to a lot of
different sports.
Let’s begin with a picture of the supervision tree when LiveMatch is transmitting some matches.
The runtime implementation is the place where everything happens. It spawns processes for each new live match, broadcasts events to the LiveViews, and controls the life-cycle of each game of the app. We rely on the Elixir/OTP abstractions and their design principles to build it, such as the GenServer, DynamicSupervisor, Phoenix.PubSub, and mechanisms to monitor nodes in our Elixir cluster.
The foundation of our engine is built by two main modules, the MatchServer
and the MatchSupervisor
, combined with
the Phoenix.PubSub
system as a middleware to handle the communication between all the entities of the app.
For example, each live match in the app is a supervised Elixir process handled by the MatchServer
module,
which uses the GenServer
behaviour. This module is responsible for dealing with everything related to the
transmission of that match, such as time, score updates, and timeline events. It’s a live thing inside our app.
# match_server.ex
defmodule LiveMatch.Matches.MatchServer do
use GenServer
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: opts[:name])
end
@impl true
def init(opts) do
Phoenix.PubSub.subscribe(@pubsub_server, "match:#{opts[:match].id}:updates")
Phoenix.PubSub.subscribe(@pubsub_server, "match:#{opts[:match].id}:events")
{:ok, set_state(opts), {:continue, :broadcast_live}}
end
@impl true
def handle_continue(:broadcast_live, %{match: match} = state) do
# Broadcast that match is live
:ok = broadcast!("match:#{match.id}:live", {:live, state.match})
{:noreply, state}
end
@impl true
def handle_info(%Event{type: :kick_off}, %{match: %Match{period: :pre_match}} = state) do
# Handle the kick_off event starting the match time
# broadcasts the kick_off event
# schedule_count_up_time()
end
@impl true
def handle_info(%Event{type: :half_time}, state) do
# Handle the half_time event stopping the match time
# broadcasts the half_time event
end
@impl true
def handle_info(%Event{type: :kick_off}, %{match: %Match{period: :half_time}} = state) do
# Handle the kick_off event, changing the match period state
# to :second_half if the current period is :half_time
#
# The match time starts to count again
# broadcast the time and the kick_off event of the second_half
#
# schedule_count_up_time()
end
@impl true
def handle_info(%Event{type: :full_time}, %{match: %Match{period: :second_half}} = state) do
# Handle the full_time event, changing the match period state
# to :full_time if the current period is :second_half
#
# broadcast it and stops the time
end
@impl true
def handle_info({:match_updated, match}, state) do
# Handle some match updates comming from the database.
# For example, when a team scores we handle that event here
# and broadcast the goal
end
@impl true
def handle_info(:count_up, %{match: %Match{period: period}} = state) when period in @playing do
# Handles the match time, incrementing the time by 1
# broadcast the time change then
# schedule_count_up_time() again
end
def handle_info(_event, state), do: {:noreply, state}
defp schedule_count_up_time do
Process.send_after(self(), :count_up, :timer.minutes(1))
end
defp broadcast!(topic, event) do
Phoenix.PubSub.local_broadcast(@pubsub_server, topic, event)
end
end
The MatchSupervisor
, in turn, uses the DynamicSupervisor
behaviour, which is responsible for starting,
stoping and monitoring MatchServer
s.
# match_supervisor.ex
defmodule LiveMatch.Matches.MatchSupervisor do
use DynamicSupervisor
def start_link(_init_arg) do
DynamicSupervisor.start_link(__MODULE__, nil, name: __MODULE__)
end
def start_live_match(%Match{} = match) do
spec = {MatchServer, [match: match])}
DynamicSupervisor.start_child(__MODULE__, spec)
end
def stop_live_match(%Match{} = match) do
pid =
match.id
|> MatchServer.via_tuple()
|> GenServer.whereis()
DynamicSupervisor.terminate_child(__MODULE__, pid)
end
# ...
end
It may be complicated at first, specially if you’re unfamiliar with the Elixir/OTP behaviours, but try to follow the comments and the function name meanings. We believe they will help.
Transmitting Matches
It’s time to simulate the transmission of a match, from going live with it to broadcasting events and updates.
We have a function in our MatchSupervisor
called start_live_match
. It expects a %Match{}
struct, which
is representation of a match in our database. It will spawn a new process for the Brazil x Spain
match and
monitor the game in case of something bad happens.
iex> match = Matches.get_match!(1)
%Match{
id: 1,
away_score: 0,
home_score: 0,
kick_off: ~N[2022-12-18 12:00:00],
location: "Lusail Stadium",
live: false,
time: 0,
period: :pre_match,
home_id: 1,
home: %Team{name: "Brazil", abbreviation: "BRA", ...},
away_id: 2,
away: %Team{name: "Spain", abbreviation: "SPA", ...},
events: [],
...
}
# This function delegates to MatchSupervisor.start_live_match/2
iex> Matches.start_live(match)
{:ok, #PID<0.549.0>}
Yay! We have a live match!
When the app spawns a MatchServer
, the callback function c:init/1
runs, subscribing our new process to two
topics in our PubSub. Next, the first c:handle_continue/2
callback of the server is called, broadcasting
that our match is live.
@impl true
def init(opts) do
Phoenix.PubSub.subscribe(@pubsub_server, "match:#{opts[:match].id}:updates")
Phoenix.PubSub.subscribe(@pubsub_server, "match:#{opts[:match].id}:events")
{:ok, set_state(opts), {:continue, :broadcast_live}}
end
@impl true
def handle_continue(:broadcast_live, %{match: match} = state) do
:ok = broadcast!("match:#{match.id}:live", {:live, state.match})
{:noreply, state}
end
Now, we need to kick off the match. We will do that by creating a new event to that specific game. There’s a function
in our main context called Matches.create_event/1
, which inserts an event in our database and broadcasts it to
the topic match:#{event.match_id}:events
.
defmodule LiveMatch.Matches do
# ...
def create_event(attrs) do
%Event{}
|> Event.changeset(:insert, attrs)
|> Repo.insert()
|> broadcast!()
end
# ...
defp broadcast!({:ok, event}) do
Phoenix.PubSub.broadcast!(LiveMatch.PubSub, "match:#{event.match_id}:events", event)
{:ok, event}
end
defp broadcast!({:error, _changeset} = error, _), do: error
end
Let’s create two events, the first as a comment
and the second starting the match. Those events will also appear
in the match timeline.
iex> Matches.create_event(%{
match_id: match.id
text: "Lineups are announced and players are warming up.",
type: :comment,
time: 0,
period: :first_half
})
{:ok, %Event{...}}
iex> Matches.create_event(%{
match_id: match.id
text: "First Half begins.",
type: :kick_off,
time: 0,
period: :first_half
})
{:ok, %Event{...}}
Both MatchServer
and MatchLive.Show
subscribe to the topic match:#{match.id}:events
. Once the broadcasted
message arrives at their process mailboxes, their handle_info
callback function is called. Let’s take a look at
how we are handling the kick_off
and comment
events in both processes.
The handle_info
in MatchLive.Show
will update its state with the new event. That assign will trigger a browser
update to sync the new state.
# match_live/show.ex
def handle_info(%Event{} = event, socket) do
socket = assign(socket, :events, [event])
{:noreply, socket}
end
For the MatchServer
, in turn, only the kick_off
event matters, because that’s how it controls the match
period. There are additional callbacks that deal with different periods of the match, but they follow the same
idea, so we’re not showing them here.
# match_server.ex
def handle_info(%Event{type: :kick_off}, %{match: %Match{period: :pre_match}} = state) do
# Change the match period from :pre_match to :first_half
# start time and broadcasts it
end
Let’s recap the process of transmitting a match in LiveMatch:
-
Start the match with
Matches.start_live/2
; -
The
MatchServer
that manages the process subscribes to some PubSub topics, and notifies the app that there’s a new match going on; -
Create events and broadcast them with facts about the match, triggering updates in both the LiveLivew
and the
MatchServer
.
We can also ends a match transmission, which is basically stopping the process with the match. You can see that process in action by checking the simulations we built in LiveMatch.
Reusability
Frontend people know the importance of building reusable pieces of code. Design systems, the formal name for a collection of user interface elements and decisions, are popular because they help developers create applications faster by organizing them in a single place.
Thanks to the Phoenix.Component behaviour, we
can also quickly build reusable UI code in LiveMatch. It combines the power of functions and HEEx templates
to create reusable elements anywhere within the app.
For example, we use the match_card
component in the three sections the matchday page. Its goal is to
encapsulate the code that displays the matches according to its group. We can also use other Phoenix components,
as we do with the match_time
and the link
.
defmodule LiveMatchWeb.MatchLive.Components do
use Phoenix.Component
attr :match, :map, required: true
attr :show_path, :string, required: true
def match_card(assigns) do
~H"""
<article class="Match-Card" id={"match-#{@match.id}"}>
<.link navigate={@show_path}>
<div class="Match-Card__grid">
<div class="Match-Card__teams">
<div class="Match-Card__row">
<span><%= @match.home.name %></span>
<b><%= @match.home_score %></b>
</div>
<div class="Match-Card__row">
<span><%= @match.away.name %></span>
<b><%= @match.away_score %></b>
</div>
</div>
<.match_time match={@match} />
</div>
</.link>
</article>
"""
end
end
Note that there’s a line at the top of the function called attr
. It’s a way to describe what attributes the
component expects to render correctly and are similar to the React PropTypes.
Lastly, we must import the module that defines our component whenever we want to use it. For example, to use
the match_card
in the matchday page, we need to do the following:
# show.ex
defmodule LiveMatchWeb.MatchLive.Index do
use LiveMatchWeb, :live_view
import LiveMatchWeb.MatchLive.Components
end
# index.html.heex
~H"""
<div id="live-matches" class="Match-Grid" phx-update="append">
<%= for match <- @live do %>
<.match_card match={match} show_path={Routes.match_show_path(@socket, :show, match)} />
<% end %>
</div>
"""
It can look a bit odd now, but you get used to it.
CSS-out-of-JS
We’re naive enough to code our CSS. The experience has been great thanks to the CSS variables, the Grid Layout, and the color-scheme media-query.
CSS variables made a lot of things possible in LiveMatch, removing the need to mix styles with JavaScript. First,
we’re configuring the primary colors, typography, and base sizes. Next, we’re updating the root’s font size
when the viewport is at least 800px
wide. Finally, we’re changing how the app will behave if the user has
dark mode active.
The app uses the rem
unit, which will always be relative to the root’s size. So, if we change the
--base-font-size
value in :root
, everything will react to that new value. Same thing for the
prefers-color-scheme
media query in the code.
Using variables as roles, like the --color-background
one, allow us to play with our stylesheets without
affecting their semantics (e.g., changing --color-orange
to blue
).
:root {
--base-font-size: 1rem;
--small-font-size: 0.875rem;
--smallest-font-size: 0.75rem;
--base-font-family: 'Inter', sans-serif;
--color-orange: #FF6B00;
--color-dark-gray: #242424;
--color-white: rgba(255, 255, 255, 0.87);
--color-black: rgba(0, 0, 0, .85);
--color-background: var(--color-white);
--color-typography: var(--color-black);
--color-border: #EFEFEF;
--color-border-hover: var(--color-black);
--color-timeline: rgba(239, 239, 239, .45);
background-color: var(--color-background);
color: var(--color-typography);
font-size: var(--base-font-size);
}
@media (min-width: 800px) {
:root {
--base-font-size: 1.25rem;
}
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--color-dark-gray);
--color-typography: var(--color-white);
--color-border: rgba(255, 255, 255, .15);
--color-border-hover: var(--color-white);
--color-timeline: rgba(0, 0, 0, .18);
}
}
Our match grid should be the most complex thing in our stylesheet, but, thanks to the CSS grids, it’s not. We
want one that adapts to the user’s viewport. If we can have match cards 300px
wide, we do; otherwise, they
will fill the entire row.
We achieved our goal with the following lines:
.Match-Grid {
display: grid;
grid-gap: 1rem;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
Conclusion
LiveMatch started as a side-project to test some approaches on how to build real-time apps with Elixir and Phoenix in a domain we like. The only requirement was to keep things simple.
The results were very satisfying, considering the goals for the project and the time invested. We built all that with minimal code possible. We focused only on what matters, leaving the rest to our tech-stack.
We are already thinking about ideas and possibilities to continue the development of LiveMatch, so stay tuned to get the latest news on the project.
Thanks, everyone!
See you in the next post!