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.

Both browser windows are transmitting matches in sync.

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.

The node3 is up and ready to work.

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.

We turned the node2 off (terminal on the right) on purpose to simulate a disconnection between the browser and the server node sending updates. Did you notice any failures on that page? 😅

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.

matchday
Does all that look promising to you? We hope so!

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:

  1. The app sends the request to MatchLive.Index every time a new user enters the matchday page;
  2. Then, the module calls its mount function. We get our initial data - the list of matches - at that moment;
  3. 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>
  1. 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.slug == match.slug)) 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.slug == match.slug)) 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.

Both browser windows are transmitting matches in sync.

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.

app-supervision-tree
The app supervision tree.

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 MatchServers.

# 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!

supervision-tree

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{...}}
app-supervision-tree
This is how the timeline of Brazil x Spain looks like after the events.

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!