Skip to content

Architecture

The Problem

Complex real-time systems — robotics, autonomous vehicles, signal processing pipelines — share a common challenge: many components need to run concurrently, share data, and react to events with minimal latency. As these systems grow, the interconnections between components grow even faster.

Consider a robot that processes camera images, runs sensor fusion, plans paths, and controls actuators. Each of these subsystems needs data from others, often on different timescales. When something changes, downstream components need to react — sometimes in microseconds.

Traditional Approaches (and Their Pain)

Threads + Shared State + Locks

The "obvious" approach: give each component a thread, share data with mutexes.

graph LR
    subgraph "Traditional: Threads + Shared State"
        T1[Thread 1<br/>Camera] -->|lock| S[(Shared<br/>State)]
        T2[Thread 2<br/>Planner] -->|lock| S
        T3[Thread 3<br/>Motor] -->|lock| S
        T4[Thread 4<br/>Sensors] -->|lock| S
    end

This works for small systems, but quickly becomes unmanageable:

  • Deadlocks when lock ordering isn't perfect
  • Priority inversion when high-priority threads wait on low-priority ones
  • Debugging nightmares — race conditions that appear once every thousand runs
  • Tight coupling — every component knows about the shared state layout

Callback Spaghetti

Event loops with registered callbacks avoid locks but create their own problems:

  • Deeply nested callbacks are hard to follow
  • Error propagation becomes manual
  • No natural parallelism — everything runs on one thread unless you manage it yourself

NUClear's Approach

NUClear takes the best ideas from reactive programming and actor models, then uses C++ template metaprogramming to eliminate the runtime costs:

graph LR
    subgraph "NUClear: Reactors + Messages"
        R1[Camera<br/>Reactor] -->|emit Image| MB((Message<br/>Bus))
        R2[Planner<br/>Reactor] -->|emit Plan| MB
        R3[Motor<br/>Reactor] -->|emit Command| MB
        R4[Sensor<br/>Reactor] -->|emit Reading| MB
        MB -->|trigger| R1
        MB -->|trigger| R2
        MB -->|trigger| R3
        MB -->|trigger| R4
    end

The key insight: components don't call each other — they emit data, and interested parties react to it.

This means:

  • No locks needed — messages are immutable (shared_ptr<const T>)
  • No coupling — reactors don't know who consumes their messages
  • Natural parallelism — the scheduler dispatches reactions to thread pools automatically
  • Type safety — message routing is resolved at compile time

The Reactor Pattern

A Reactor is a self-contained component that:

  1. Declares its interests — "When X happens, run this function"
  2. Processes events — the function runs with the relevant data
  3. Produces outputs — emits new messages for others to react to
class Vision : public NUClear::Reactor {
    Vision(std::unique_ptr<NUClear::Environment> env)
        : Reactor(std::move(env)) {

        // Declare interest: when a new Image arrives, run this
        on<Trigger<Image>>().then([this](const Image& img) {
            auto detections = process(img);
            emit(std::make_unique<Detections>(detections));
        });
    }
};

No thread management. No mutexes. No callbacks to wire up. The reactor says what it cares about, and the system handles the rest.

High-Level Architecture

graph TB
    subgraph PowerPlant
        direction TB
        S[Scheduler]
        subgraph "Reactor A"
            R1A[Reaction 1]
            R1B[Reaction 2]
        end
        subgraph "Reactor B"
            R2A[Reaction 3]
            R2B[Reaction 4]
        end
        subgraph "Reactor C"
            R3A[Reaction 5]
        end
        subgraph "Thread Pools"
            TP1[Default Pool]
            TP2[Custom Pool]
            TP3[Main Thread]
        end
    end

    R1A & R1B & R2A & R2B & R3A -->|emit| S
    S -->|dispatch| TP1
    S -->|dispatch| TP2
    S -->|dispatch| TP3

The hierarchy is straightforward:

  • A PowerPlant is the top-level container for the entire system
  • A PowerPlant contains Reactors — your self-contained components
  • Each Reactor declares Reactions — event handlers registered with on<>().then()
  • When a Reaction runs, it can emit messages that trigger other Reactions

This creates a data-driven execution model: components don't call each other directly. Instead, they emit data, and the scheduler dispatches reactions in response.

Design Philosophy

NUClear is built on the principle of zero-cost abstractions:

  • Compile-time DSL — the on<Trigger<T>, With<U>> syntax is resolved entirely by the C++ template system. There's no runtime parsing, no string matching, no vtable dispatch for message routing.
  • Type-safe messaging — if you try to trigger on a type that doesn't match your callback signature, you get a compile error, not a runtime crash.
  • Minimal runtime overhead — the scheduler uses a priority queue and condition variables. Messages are shared via shared_ptr<const T> with no copying.
  • Composable DSL words — DSL components (Trigger, With, Every, Buffer, etc.) fuse together at compile time into a single optimised handler.

How NUClear Compares

Aspect NUClear ROS 2
Language C++14+ C++/Python
Message routing Compile-time types Runtime topic strings
Threading Built-in scheduler with pools Executor model
Overhead Near-zero (templates) Serialization + IPC
Scope In-process (+ network) Distributed by default
Real-time Designed for it Possible (DDS)

NUClear is intentionally focused on in-process concurrency with optional networking, rather than being a distributed middleware. This keeps it lightweight and predictable — exactly what you want when reactions need to complete in microseconds.

Academic Foundation

NUClear's architecture is formally described in the research paper "NUClear: A Loosely Coupled Software Architecture for Humanoid Robot Systems" (Houliston et al., 2016, Frontiers in Robotics and AI 3:20).

The paper introduces several key concepts that underpin the framework:

Co-Messages

In a traditional message-passing system, if a module needs data from multiple sources, it must subscribe to each type independently and cache values itself. This doubles the number of callbacks and adds cache management code.

NUClear solves this with co-messages: supplementary data types that are automatically provided alongside the primary trigger data. When a Trigger<T> fires, the most recent values of all With<U> types (the co-messages) are bound into the reaction automatically. This eliminates per-module caching and reduces the number of subscription handlers required.

Virtual Data Store

As a byproduct of the co-messaging system, NUClear maintains the latest value of every emitted message type in an internal data store. This creates a virtual global store — modules gain the data availability advantages of a blackboard architecture without the tight coupling. The store is not an explicit design element but an emergent property of retaining data for co-message binding.

Compile-Time Message Routing

Unlike systems that use runtime topic strings or message brokers (e.g., ROS Master), NUClear resolves all message routes at compile time using C++ template metaprogramming. The result is that emitting a message directly invokes the subscribed reactions with no broker lookup — approaching the performance of a direct function call.

Transparent Multithreading

Messages in NUClear are immutable after emission (shared_ptr<const T>). Since no reaction can modify a message, multiple reactions can safely process the same data in parallel without locks. The thread pool scheduler dispatches reactions automatically, providing transparent parallelism without developer intervention.