How Elixir GenServers, Phoenix LiveView Built a Logic Circuit Simulator

Tags

Circuits

One of the more interesting topics for me in college was logic circuit simulators. You start off with on/off switches and lightbulbs, then introduce gates such as OR, AND, NOT, XOR, etc. From those simple building blocks you can simulate more complex things such as memory by looping an output of two NOR gates, or number displays with a complex mesh of AND/ORs feeding each of the seven segments of the 8.

Shortly after Phoenix LiveView went public, I saw an advertisement for a mechanical logic circuit simulator, and it got me thinking how a logic circuit simulator would work with Elixir GenServers representing each node, message passing for the outputs/inputs, and a Phoenix LiveView frontend.

In this article, I will review the logic simulatior (logic_sim) and the Phoenix LiveView frontend (logic_sim_liveview) that resulted from that idea. If you want to jump in and play around with LogicSim first, check out the hosted version here.

GenServers

First, lets explore what it means to be a Node in LogicSim. Not to be confused with the Elixir Node module, a LogicSim Node represents one simple circuit in LogicSim, such as a switch; lightbulb; or a gate, like and, not, or. A Node will have zero or more inputs, zero or more outputs, and the ability to calculate its outputs from its inputs. Some nodes may need to track some sort of inherently internal state (such as an on/off switch), but we should be careful not to store too much state in a Node or we risk ruining the fundamental concept that circuits are always built from a small handful of basic building blocks.

A Node also needs to track the output nodes it is connected to on each of its outputs, and be able to tell a remote node when its output has changed. On the flip side, a Node also needs to listen for incoming messages about any of its inputs, and when it receives a change, recalculate outputs, as well as notify its output nodes.

This concept of tracking state, updating state, and message passing is perfect for GenServers. We are going to put all the common code for a Node into the Node module, and pull it into individual circuits via use LogicSim.Node. The common code accounts for the majority of the code required, so our actual circuits have very little code. For example, here is the complete Or circuit:

defmodule LogicSim.Node.Or do
  use LogicSim.Node, inputs: [:a, :b], outputs: [:a]

  def calculate_outputs(_state, %{a: a, b: b} = _input_values) do
    %{a: a or b}
  end
end

We pass the list of two inputs and one output into the use LogicSim.Node call, and simply implement the callback for calculate_outputs/2, which gives us the current state and input values map, and takes back the new output values map.

In the code for OnOffSwitch we see the use of additional internal state, initialized during the use LogicSim.Node, and then destructured from state and used to calculate the output value:

defmodule LogicSim.Node.OnOffSwitch do
  use LogicSim.Node, outputs: [:a], additional_state: %{on: false}

  def toggle(server) do
    GenServer.call(server, :toggle)
  end

  def handle_call(:toggle, _from, %{on: on} = state) do
    on = !on
    state = %{state | on: on}
    state = set_output_value(:a, on, state)
    {:reply, :ok, state}
  end

  def calculate_outputs(%{on: on} = _state, _input_values) do
    %{a: on}
  end
end

We also see that the OnOffSwitch has the ability to “toggle” its internal state via a GenServer call, which hooks back into the Node function set_output_value that handles updating the state and notifying any nodes connected to outputs. Finally, OnOffSwitch shows us inputs/outputs are optional.

Macros

The __using__ macro in LogicSim.Node is what allows us the simplicity of replacing all the boilerplate common code with a call to use LogicSim.Node. By injecting the outputs, inputs, and additional state into the start_link call with unquote, the outside caller to start_link only needs to specify the variable opts (e.g. :listeners) when starting a node. The :listeners list specifies the pids of processes that want to be notified any time the state of the node changes. We will hook into this later when we put a LiveView front end on LogicSim.

  defmacro __using__(opts) do
    inputs = Keyword.get(opts, :inputs, [])
    outputs = Keyword.get(opts, :outputs, [])
    additional_state = Keyword.get(opts, :additional_state, Macro.escape(%{}))

    quote do
      use GenServer
      require Logger
      @behaviour LogicSim.Node

      def start_link(opts \\ []) do
        output_nodes = Enum.reduce(unquote(outputs), %{}, &Map.put(&2, &1, %{}))
        output_values = Enum.reduce(unquote(outputs), %{}, &Map.put(&2, &1, false))
        input_values = Enum.reduce(unquote(inputs), %{}, &Map.put(&2, &1, false))
        listeners = Keyword.get(opts, :listeners, [])

        state =
          unquote(additional_state)
          |> Map.put(:inputs, unquote(inputs))
          |> Map.put(:outputs, unquote(outputs))
          |> Map.put(:output_nodes, output_nodes)
          |> Map.put(:output_values, output_values)
          |> Map.put(:input_values, input_values)
          |> Map.put(:listeners, listeners)

        GenServer.start_link(__MODULE__, state)
      end

Note that we also tag @behaviour LogicSim.node inside the macro. This ensures any module that uses LogicSim.Node is warned if they don’t implement the required calculate_outputs function.

Linking and Message Passing

The rest of the code in LogicView.Node.__using__ revolves around linking and message passing. Each node is responsible for tracking a list of output nodes for each one of its outputs. It does this by associating a map of input pids/inputs for each output. For example, the :output_nodes for a node that is connected from its :a output to node 0.671.0’s input :a and node 0.675.0’s input :b would look like:

%{a: %{#PID<0.671.0> => :a, #PID<0.675.0> => :b}}

Maybe you see in this data model the issue that I just saw as I was writing this article: An output can be connected to two inputs on different nodes, but can one output of a node be connected to multiple inputs on a single node? The answer right now is no, the first connection will get overwritten by the second, so I’ll throw that on the todo list to fix. (Side note: if you ever want to find the issues in your code, write a blog post explaining your code.)

To link two nodes, we call the client function link_output_to_node, which calls the nodes GenServer with a :link_output_to_node message:

      def handle_call(
            {:link_output_to_node, output, node, input},
            _from,
            %{output_nodes: output_nodes, output_values: output_values} = state
          ) do

        output_nodes = put_in(output_nodes, [output, node], input)

        set_node_input(node, input, Map.fetch!(output_values, output))
        state = %{state | output_nodes: output_nodes}
        send_state_to_listeners(state)
        {:reply, :ok, state}
      end

Here we add the input node to the list of outputs, noting which input on the input node it is connected to. We then send the current state of this nodes output to the other node via set_node_input.

When a node needs to tell a linked node that its input should be updated, it calls the client function set_node_input, which casts a :set_node_input message to the GenServer. Side note: this was originally a call instead of a cast, but the first time I created a circuit that looped back on itself, the nodes deadlocked.

      def set_node_input(node, input, input_value) do
        GenServer.cast(node, {:set_node_input, input, input_value})
      end

      def handle_cast(
            {:set_node_input, input, input_value},
            %{
              input_values: input_values,
              output_values: old_output_values
            } = state
          ) do
        if Map.get(input_values, input) != input_value do
          input_values = Map.put(input_values, input, input_value)
          output_values = calculate_outputs(state, input_values)
          state = %{state | input_values: input_values}

          state =
            output_values
            |> Map.keys()
            |> Enum.filter(fn key -> old_output_values[key] != output_values[key] end)
            |> Enum.reduce(state, fn output, state_acc ->
              set_output_value(output, output_values[output], state_acc, false)
            end)

          send_state_to_listeners(state)
          {:noreply, state}
        else
          {:noreply, state}
        end
      end

We first check to see if the new input value is actually different from the current one. If it isn’t, then there is no need to do anything as our outputs won’t have changed. If it has changed, we calculate the new output values, and then if that results in any changed outputs, we apply those changes (including recursive calls to set_node_input for any connected nodes). Finally we send our new state to any external listeners.

Since the intention of using LogicSim is to display the circuits in some way, we need a way to let external systems know when the state of a node changes. We saw in use LogicSim.Node that we can specify a list of external pids via the :listeners opt. send_state_to_listeners, which we saw used above, simply loops through the list and calls send with the current state:

      defp send_state_to_listeners(%{listeners: listeners} = state) do
        listeners
        |> Enum.map(&send(&1, {:logic_sim_node_state, self(), state}))
      end

This allows us to hook into LogicSim with LiveView.

LiveView

I could probably write a full blog post just on the LiveView implementation of LogicSim, but for now I am going to concentrate on the touch points between LogicSim and the LiveView interface.

The design of LogicSim.Node makes instantiating a node as simple as calling start_link! on the node type you want. Since we want the LiveView process to be notified of any state changes, we pass self() in the :listeners opt:

  def create_node(type, x, y, uuid \\ nil) do
    node_process = type.start_link!(listeners: [self()])
    node_state = Node.get_state(node_process)

    %{
      uuid: uuid || UUID.uuid4(),
      type: type,
      node_process: node_process,
      node_state: node_state,
      top: y,
      left: x
    }
  end

Our LiveView implementation tracks its own information alongside the reference to the node process, including top/left coordinates and the current state of the node. This way LiveView can render the state of the node without having to ask it on every render.

Connecting Nodes

Connecting nodes in LiveView hooks into the above mentioned link_output_to_node function:

  defp connect_nodes(%{nodes: nodes} = socket, output_uuid, output_output, input_uuid, input_input) do
    %{node_process: input_node_process} = get_node_by_uuid(nodes, input_uuid)
    %{node_process: output_node_process} = get_node_by_uuid(nodes, output_uuid)

    Node.link_output_to_node(
      output_node_process,
      String.to_existing_atom(output_output),
      input_node_process,
      String.to_existing_atom(input_input)
    )

    {:noreply, assign(socket, select_output_for_input: nil, select_input_for_output: nil)}
  end

Other than connecting nodes, the only other input into the system is currently by clicking on an OnOffSwitch, which simply calls the toggle function you saw above:

  def handle_node_click(%{type: OnOffSwitch, node_process: node_process}, socket) do
    OnOffSwitch.toggle(node_process)
    {:noreply, socket}
  end

The LiveView assigns (and therefore the UI) are updated via the :logic_sim_node_state message that we saw above. Since each LiveView process is a GenServer, the message comes in as a handle_info:

  def handle_info({:logic_sim_node_state, from, node_state}, %{assigns: %{nodes: nodes}} = socket) do
    nodes
    |> Enum.split_with(fn %{node_process: node_process} -> node_process == from end)
    |> case do
      {[], _nodes} ->
        Logger.warn("Received :logic_sim_node_state for untracked node: #{inspect(from)}")
        {:noreply, socket}

      {[node], nodes} ->
        Logger.debug("Received :logic_sim_node_state with node_state: #{inspect(node_state)}")
        node = %{node | node_state: node_state}
        nodes = [node | nodes]
        {:noreply, assign(socket, nodes: nodes)}
    end
  end

We simply look up the node in the LiveView state and update our copy of its state in our assigns. This triggers LiveView to re-render the view.

Click Location

Most of the click interactions in LogicSim LiveView simply use phx-click="node_click_<%= uuid %>" on the div to trigger a server side event, with a corresponding def handle_event("node_click_" <> uuid, _, socket) do to parse the uuid and handle the click. It gets a bit tricky when you want to figure out where on an element the user clicked. This is necessary to know where to add a new node, or where to move it to. LogicSim LiveView uses the following JavaScript to capture the click event and then set the phx-value of the element clicked to the x,y coordinates of the click.

window.addEventListener("click", e => {
  if (e.target.getAttribute("phx-click") && e.target.getAttribute("phx-send-click-coords")) {
    let x = Math.floor(e.clientX - e.target.getClientRects()[0].x);
    let y = Math.floor(e.clientY - e.target.getClientRects()[0].y);
    let val = `${x},${y}`;
    e.target.setAttribute("phx-value", val)
  }
}, true)

As you can see, it only activates if phx-send-click-coords="true" is set on the element. Whenever we want to track where a user is about to click, we put a selection div covering the whole screen, which has phx-click="selection_mode_div_clicked" phx-send-click-coords="true".

We handle that event on the server here:

  def handle_event("selection_mode_div_clicked", params, %{assigns: %{selection_mode: selection_mode}} = socket) do
    [x, y] = String.split(params, ",")
    {x, _} = Integer.parse(x)
    {y, _} = Integer.parse(y)
    handle_selection_mode_div_clicked(selection_mode, x, y, socket)
  end

The Result

Screenshot of LogicSimLiveview

That’s about it for this blog post. You can find the full code for LogicSim here, the full code for the LiveView server here, and a deployment of the LiveView server here.

DockYard is a digital product agency offering exceptional strategy, design, full stack engineering, web app development, custom software, Ember, Elixir, and Phoenix services, consulting, and training. With a nationwide staff, we’ve got consultants in key markets across the United States, including San Francisco, San Diego, Phoenix, Dallas, Detroit, Pittsburgh, Baltimore, and New York.