Building a Remote Control Car from Scratch Using Elixir

Elixir is undoubtedly one of the most comprehensive full stack languages available, offering battle-tested reliability and fault-tolerance on the backend. This is thanks to its origins in Erlang, the BEAM VM and OTP, powerful and agile frontend development thanks to LiveView and the ability to write to hardware with Nerves (not to mention the exciting developments happening in the machine learning space). 

Our Americas office created a project that takes full advantage of that fullstack capability- a remote control car that can be controlled from your phone. It has all the components from the car to the app, controlled and written in Elixir. 

Here’s how they did it.

Please accept marketing-cookies to watch this video.

Background

During ElixirConf, we set a Scalextric racetrack at our sponsor booth where people meeting us were able to play around with the race cars. It’s a fun way to encourage people to come to the stand, but we felt that something was missing, there was no connection between the fun we had on the stand and the languages we love (Erlang and Elixir).

So we thought it would be cool to assemble our own remote car using Elixir. We went ahead and got rid of the cables and the track, which were physical restrictions to the fun we envisioned.

That’s how the idea was born.

The initial implementation was for us to gain more knowledge about Nerves and IoT in general. Our approach was to assemble some RaspberryPi with a motor driver and see if we could control the car over WiFi.

This is when we decided to start a very rough car prototype to see how easy it was to get the whole project running in Elixir. 

Requirements

We wanted to ensure we only used Elixir / Erlang Ecosystem in our stack:

• Erlang/OTP 25.1.2

 • Elixir 1.14.2

You also need the Nerves bootstrap mix archive in order to create the project scaffold and provide deeper integration within mix.

mix archive.install hex nerves_bootstrap

Theory

Let’s first recap some theory and concepts:

The basic idea is for us to move a pair of wheels. In order to do that, we need a device that is capable of power and can control a couple of motors. We decided to use a L298n motor driver that is easily available in the local electronics stores.

The L298n is a device that can power up to two motors and is able to control their speed by issuing PWM commands.

L298N module pinout

We powered the device using four AA rechargeable batteries that are connected via 12v and GND pins.

We also needed to know that for moving the wheels, we had to write GPIO commands to IN1, IN2, IN3 and IN4, while controlling the speed via PWM over the pins ENA and ENB (motor A and B respectively).

At the end of the day, we had this circuit implemented: 

Starting the project

We started with a blank project and chassis:

First, we start with a blank Nerves project that will give us the scaffold we need:

export MIX_TARGET=rpi4
mix nerves.new jaguar

Before we compiled the project, we added a couple of dependencies that we needed:

# ...
{:vintage_net, "~> 0.12", targets: @all_targets},
{:vintage_net_wifi, "~> 0.11.1", targets: @all_targets},
{:pigpiox, "~> 0.1", targets: @all_targets},
# ...

The dependencies above helped us with WiFi connectivity and GPIO / PWM commands.

2.2 First steps

Now that we had all dependencies in place we can proceed to compile the project:

mix do deps.get, compile

We now needed to focus on how to make the wheels move. At first, you might have to do some scaffolding to test your setup:

The motors themselves don’t have any polarity, so there is no risk of magic smoke. But keep this in mind in case your wheels spin backwards.

Now, let’s connect the other components and give it a try.

Tune in

After having a working setup, we need to connect to the outside world. We provided a very naive and basic way to connect to the back-end via TCP. But first, we need to make sure our device can connect to the internet at startup.

Nerves has a third-party library that deals with networking setup and also provides WiFi configuration utilities. There are different ways to set this up but for simplicity, we are going to set the configuration statically in your config/target.exs file: 

config :vintage_net,
  config: [			
{"wlan0", %{
       type: VintageNetWiFi,
        vintage_net_wifi: %{

					
         networks: [
            %{

					
             key_mgmt: :wpa_psk,
              ssid: "my_network_ssid",
              psk: "a_passphrase_or_psk",
} ]
					
},
					
       ipv4: %{method: :dhcp}
      }					
} ] 

For more information about the different configuration options and setup choices, refer to the documentation.

Once your WiFi connectivity is configured, we need to make sure that we can connect to the back-end via TCP. To do so, just create a new GenServer that connects via :gen_tcp at initialization, pretty much like this: 

## GenServer callbacks
@impl true
def init(opts) do
  backend = Keyword.get(opts, :backend, "localhost") |> to_charlist()
  port = Keyword.get(opts, :port, 5000)
  state = %{socket: nil, backend: backend, port: port}
  {:ok, state, {:continue, :connect}}
End

@impl true
def handle_continue(:connect, state) do
  Logger.info("connecting to #{inspect(state.backend)}")
  {:ok, socket} = :gen_tcp.connect(state.backend, state.port, keepalive: true)
  _ref = Port.monitor(socket)
  {:noreply, %{state | socket: socket}}
end 
#...

Powering the device

There is not much to this as we use a standard MakerHawk Raspberry Pi UPS that fits right down the Raspberry Pi. It is powered by two rechargeable 18650 Li-PO batteries. This hat also works as a charging device. 

Moving the wheels

Moving the wheels is a straightforward process. We only need to take into consideration the PIN layout that we are using for communicating with the motor driver. In this case we are using the following layout: 

IN1 – GPIO 24 (A forward)

IN2 – GPIO 23 (A backward)

IN3 – GPIO 17 (B forward)

IN4 – GPIO 22 (B backward)

ENA – GPIO 25 (A speed)

ENB – GPIO 27 (B speed)

The PIN numbers correspond to the pinout layout for Raspberry Pi 4 model B.

With the pins wired, we can now issue some commands to prepare the pins for output and set the initial motor speed: 

Enum.map([in1, in2, in3, in4], fn pin ->
  Pigpiox.GPIO.set_mode(pin, :output)
  Pigpiox.GPIO.write(pin, 0) # Stop
end)
speed = 250
Enum.map([ena, enb], fn pin ->
  Pigpiox.GPIO.set_mode(pin, :output)
  Pigpiox.Pwm.gpio_pwm(pin, speed)
end)

After setup, we can change the speed of the motors on the fly: 

speed = 200
:ok = Pigpiox.Pwm.gpio_pwm(ena, speed)
:ok = Pigpiox.Pwm.gpio_pwm(enb, speed)

We can also control the motors to go forwards/backwards:

# Forwards
_ = Enum.map([in2, in4], &Pigpiox.GPIO.write(&1, 0))
_ = Enum.map([in1, in3], &Pigpiox.GPIO.write(&1, 1))
# Backwards
_ = Enum.map([in1, in3], &Pigpiox.GPIO.write(&1, 0))
_ = Enum.map([in2, in4], &Pigpiox.GPIO.write(&1, 1))

wire everything up! The idea is that we used the TCP socket we opened for listening for Erlang binary terms that when decoded, will get translated into steering instructions, that we can then translate to GPIO commands.

With the base logic drafted, we burned the firmware into the SD card and power up the device: 

MIX_TARGET=rpi4 mix do deps.get, compile, firmware, burn

Next steps

Moving on to the next part of the setup. We would need a way for sending commands to the car over the internet.

In the firmware, we have a simple interface for translating steering commands into GPIO commands. We can export those facilities over our TCP socket: 

		
		@impl true
def handle_info({:tcp, _, data}, state) do
msg = data
    |> :erlang.iolist_to_binary()
    |> :erlang.binary_to_term()
  case msg do
    {:speed, speed} ->
      Vehicle.speed(speed)
    steering when steering in @valid_moves ->
Vehicle.direction(steering)
  end
  {:noreply, state}
End
			

Keep in mind that we are using a naive approach at communicating with the back-end. A more robust mechanism would be needed if you plan to drive the car in a highway. 

3.1 The back-end

The back-end is fairly easy and is left as an exercise to the reader. Our current implementation consists of a LiveView car controller, that consists of a couple of buttons for the steering and a slider for the speed. On user input, the LiveView process will encode the information to send it to the connected car via TCP: 

# ...
def handle_event("speed", %{"value" => speed}, socket) do
  vehicle = socket.assigns.vehicle
  speed = String.to_integer(speed)
  Vehicles.speed(vehicle.pid, speed)
  {:noreply, assign(socket, speed: speed)}
end
def handle_event("stop", _params, socket) do
  vehicle = socket.assigns.vehicle
  Vehicles.stop(vehicle.pid)
  {:noreply, assign(socket, stopped: true)}
end
def handle_event(direction, _params, socket) do
  vehicle = socket.assigns.vehicle
  case direction do
    "left" -> Vehicles.left(vehicle.pid)
    "right" -> Vehicles.right(vehicle.pid)
    "forward" -> Vehicles.forward(vehicle.pid)
"backwards" -> Vehicles.backwards(vehicle.pid)
  end
  {:noreply, socket}
end
# …

4 Sources and conclusions

We are now finished! Hopefully everything is put together and, you should have something that reassembles this: 

va

We had fun assembling the kit and working with Nerves. It was easier than we expected, and we found that Nerves is a very stable and solid frame- work for deploying Elixir applications in restrained environments without friction.

Now that we finished our first proof of concept, we are going to see if this idea can be scaled and enhanced. Stay tuned for more! 

All the source code is available under MIT licence under GitHub:

• Jaguar-1 source code 

• Nerves project

• Erlang Solutions 

Need help making the most of Elixir? 

You’re in the right place. We’ve worked with the world’s biggest companies to provide transformative, mission critical solutions across a range of industries including Fintech, Health Care and Utilities providers. Learn more about our our training page. 

Keep reading

Meet the team: Erik Schön
Meet the Team: Erik Schön thumbnail

Meet the team: Erik Schön

Meet Erik Schön, Managing Director and and Nordics Business Unit Lead at Erlang Solutions. He shares his 2025 highlights and festive traditions.

Optimising for Concurrency: Comparing and contrasting the BEAM and JVM virtual machines

Optimising for Concurrency: Comparing and contrasting the BEAM and JVM virtual machines

Attila Sragli explores the BEAM VM's inner workings, comparing them to the JVM to highlight their importance.

MongooseIM 6.3: Prometheus, CockroachDB and more

MongooseIM 6.3: Prometheus, CockroachDB and more

Pawel Chrząszcz introduces MongooseIM 6.3.0 with Prometheus monitoring and CockroachDB support for greater scalability and flexibility.