Flight Simulator built in Phoenix LiveView

Building a Flight Simulator in Phoenix LiveView

Josh Price Portrait

Josh Price

3 April 2022 · 6 min read

A short history of Live View

Phoenix LiveView was first released in August 2019. It heralded the dawn of a new age of productivity, by removing multiple unnecessary layers in our standard architecture. We typically use React to talk to our Elixir backends via a GraphQL API. This is a fantastic combination of frontend and backend technologies.

There is a problem though — there are more layers than typically necessary. LiveView lets us reclaim super fast feedback cycles by removing multiple repos, multiple languages, multiple sets of type systems and API client and server code. Yeah. It’s a lot. It enables almost instantaneous feedback for developers who now have full control of their entire stack again.

We’ve gone from feedback loops that measure in the tens of minutes (at best) to the pure joy of changing something and seeing it update in the browser in seconds. The productivity boost is so dramatic, it’s guaranteed to put a smile on your face.

The motivation: UAV Rescue Competition

One of the first applications I built with Phoenix Live View was a flight simulator. Which sounds ambitious. It was actually a side effect of trying to build something even more ambitious.

To compete in the UAV Rescue Competition we needed to create a fully distributed groundstation to control a set of drones for autonomous rescue mission. The goal was to fly 10km dodging simulated weather and other obstacles like bird swarms, then enter into a shed, find the casualty and setup an audio and video stream to Outback Joe who can then state his predicament while waiting for help.

An ambitious goal indeed. We thought the combination of Elixir, Phoenix and LiveView for building a fast, distributed live ground station was an absolutely perfect match.

Phoenix Live View in Practice

We saw the promise of Live View for building such an ambitious project. However in order to prove whether LV was up to the task, we needed to prove what it could do.

Was it easy to work with? Could it play nicely with Javascript? Was it going to be performant? We also needed to break the problem down into smaller pieces.

Luckily the Phoenix Phrenzy competition came along sponsored by Dockyard (link). This provided the perfect opportunity to see if LiveView was up for the challenge.

Ultimately we were held back from building the full groundstation by time, bandwidth and expedience.

The Flight Simulator

LiveView Function Components

Let’s start with the simple stuff first. If you haven’t seen LiveView Function Components in action, you’re going to love how simple they are. They are just a pure function, taking an assigns argument and returning a Heex template in an ~H sigil. Sounds more complicated than it really is. Let’s see it in action as we define our instrument panel.

def panel(assigns) do
  ~H"""
  <ul phx-window-keydown="control_input" class="grid grid-cols-2 gap-6">
    <%= render_slot(@inner_block) %>
  </ul>
  """
end

Our panel is a Tailwind grid which renders the inner block (the child nodes passed to our panel component) which is a default slot and passed as @inner_block. We also listen to all keyboard input here at the window level using phx-window-keydown letting us handle the flight controls.

An instrument is just an item in our list of instruments in the panel.

def instrument(assigns) do
  ~H"""
  <li {assigns_to_attributes(assigns)} class="max-w-[400px] max-h-[400px] col-span-1 flex flex-col text-center bg-white">
    <%= if @inner_block do %>
      <%= render_slot(@inner_block) %>
    <% end %>
  </li>
  """
end

We’re rendering our components passed in children again (optionally). Also we pass through any assigns as attributes using the helper function assigns_to_attributes(assigns).

Compass

Screen Shot 2022-03-28 at 11.15.51 am.png

One of the best things about modern browsers is the ability to just embed SVGs anywhere. It’s like a superpower, and we’re going to make heavy use of that power for this project. I started by hand drawing the compass in SVG. Sounds weird, but I prefer the control I get by coding my SVG rather than using a drawing tool. Once I had that sketched up, all I needed was to rotate the compass SVG group around with a rotate transform, and show the actual heading as a number. We invert the bearing so that compass points the correct way. This is great because all the heavy lifting is done for us by the rotate transform, so we’re done with this instrument.

Below is our compass SVG in a simple function component that takes a @bearing prop.

def compass(assigns) do
  ~H"""
  <svg viewBox="-50 -50 100 100" xmlns="http://www.w3.org/2000/svg">
    <text>
      <%= Float.round(@bearing, 1) %>º
    </text>
    <g transform={"rotate(#{-@bearing})"}>
            ... the compass group ...
    </g>
  </svg>
  """
end

Artificial Horizon

Screen Shot 2022-03-28 at 11.18.17 am.png

This is the component that I thought might be really tricky. But with the use of SVG rotation and 2d translation transforms to do the hard stuff, this turns out to be relatively straightforward. We need to get our SVG groupings right and it’s pretty simple. I’m still drawing this by hand, so this one is a little bit more involved, but the honestly hardest bit was probably getting the groups nested appropriately and nice looking gradient fills. Not sure how folks do this cleanly in a drawing tool. Good luck to them!

def horizon(assigns) do
  ~H"""
  <svg viewBox="-50 -50 100 100" xmlns="http://www.w3.org/2000/svg">
        ... define gradients ... 

    <g transform={"rotate(#{-@roll_angle})"}>
      <g transform={"translate(0 #{@pitch_angle})"}>
        <rect fill="url(#sky)" height="200" width="200" x="-100" y="-200" />
        <rect fill="url(#ground)" height="200" width="200" x="-100" y="0" stroke="white" stroke-width="0.25" />

        <g id="pitch-tape">
          <g id="pitch-labels" fill="white" font-size="3" text-anchor="middle" alignment-baseline="middle">
                        ... 
          </g>

          <g id="pitch-angle-lines" stroke="white">
                        ...
          </g>
        </g>
      </g>
      <path id="pitch-rotate-pointer" d="M 0,-45 L -1.5,-42 L 1.5,-42 Z" fill="white" />
    </g>

    <g id="pitch-angle">
      <path id="pitch-arc" d="M-45,0 A5,5 0 1,1 45,0" fill="none" stroke="white" stroke-width="0.5" />
      <path id="pitch-ticks" d="M-44,0 A5,5 0 1,1 44,0" fill="none" stroke="white" stroke-width="2" stroke-dasharray="0.25,7.41" stroke-dashoffset="0" />
      <path id="pitch-angle-pointer" d="M 0,-45 L -1.5,-48 L 1.5,-48 Z" fill="white" />
    </g>

    <g id="level-indicator" stroke="yellow" stroke-width="1.5" stroke-linecap="round">
            ...
    </g>

    <g id="metrics">
      <text x="-45" y="45" font-size="4" stroke="#aaa" stroke-width="0.5">Altitude (m) </text>
      <text x="-45" y="40" font-size="6" stroke="white" stroke-width="0.5"><%= Float.round(@altitude) %></text>
      <text x="15" y="45" font-size="4" stroke="#aaa" stroke-width="0.5">Speed (m/s) </text>
      <text x="15" y="40" font-size="6" stroke="white" stroke-width="0.5"><%= Float.round(@speed) %></text>
    </g>
  </svg>
  """
end

The Map View

I’m lazy so it just doesn’t make sense to rebuild a map component so let’s take advantage of LiveView’s excellent JavaScript interoperability and just use the Google Maps component instead.

Originally I used LeafletJS but when I went to update this with the latest LiveView and Tailwind, it would not render the map tiles correctly, no matter what I tried. So I replaced Leaflet with Google Maps in short order and it worked beautifully. This component looks a bit pointless but we need to provide a DOM node which Google Maps can target and has a fixed size which allows it to render correctly.

def map(assigns) do
  ~H"""
  <div id="map" class="w-[400px] h-[400px]"></div>
  """
end

The Cockpit View

The cockpit view really makes this whole project sing, but after some research there was really only one JS library that was going to do most of what I wanted. ESRI

def cockpit_view(assigns) do
  ~H"""
  <div id="view" class="w-[400px] h-[400px]" phx-update="ignore"></div>
  """
end

LiveView JavaScript Interoperability with push_event

One of the most important features of LiveView is the seamless interoperability with the JavaScript ecosystem. We need that, so we can use existing libraries and code, otherwise we’ll be reinventing a lot of pieces of our system from scratch.

When I first wrote the simulator 3 years ago, the only way we had to communicate back to JS land was to render the data into a DOM node and use a hook to call a JS function when it updates. This is what that looked like.

<span
  id="location-data"
  phx-hook="Map"
  data-lat={@simulator.location.lat}
  data-lng={@simulator.location.lng}
  data-alt={@simulator.altitude}
  data-bearing={@simulator.bearing}
  data-pitch={@simulator.pitch_angle} />

This was a perfectly workable way to send data to JS libraries (in our case, for the map and cockpit view components). We had to write a JS function to extract and convert the data, which by virtue of being in a DOM node had all been cast to strings.

As of LiveView 0.14.0 there is a much cleaner way to do this. Now we can explicitly call push_event to send data directly to a JS function of your choice. We won’t have to invent a “fake” DOM node to hold data, nor will we need to do any casting of our data types as this will be done for us.

Our hooks end up looking like this:

let Hooks = {
  Map: {
    mounted() {
      this.handleEvent("mount_state", (simState) => {
        mountMap(mapState, simState)
        mountView(viewState, simState)
      })
    },
    updated() {
      this.handleEvent("update_state", (simState) => {
        updateMap(mapState, simState)
        updateView(viewState, simState)
      })
    },
  },
}

We call the mount_state event in our LiveView mount function and our update_state event in our LiveView update function (we call this after every handle_event).


def mount(_params, _session, socket) do
  if connected?(socket), do: :timer.send_interval(@tick, self(), :tick)

  {:ok,
   socket
   |> assign(simulator: @initial_state)
   |> push_event("mount_state", @initial_state)}
end

...

def update_simulator(state, socket) do
  {:noreply,
   socket
   |> assign(:simulator, state)
   |> push_event("update_state", state)}
end

Conclusion

Phoenix LiveView is a super productive tool for building ambitious applications really quickly. Although you probably won’t be building a flight simulator for your next app, it’s nice to know that something that seemed impossible was made not only possible, but straightforward by LiveView.

Take a look at the code, the combination of Elixir, Phoenix, LiveView and Tailwind is frankly a super power that you should probably keep to yourself.

Resources

Josh Price Portrait

WRITTEN BY

Josh Price

I love building and designing software. When I'm not building awesome software I'm probably BBQing or bushwalking.

Igniter

Ash Framework Consulting

Igniter - Rethinking code generation with project patching

Zach Daniel

Zach Daniel

16 July 2024 – 8 min read

Building skyscrapers from the top down

Ash

Building skyscrapers from the top down

Ben Melbourne profile picture

Ben Melbourne

3 July 2024 – 4 min read

Want to read more?

The latest news, articles, and resources, sent to your inbox occasionally.