Creating Custom DSL Words¶
How to extend NUClear's DSL with your own words that add new behavior to reactions.
How It Works¶
A DSL word is a struct that implements one or more extension points as static template methods.
The Fusion Engine discovers which points your word implements and combines them with the other words in an on<>() statement.
graph LR
subgraph "Reaction Lifecycle"
A[bind] --> B[precondition]
B --> C[get]
C --> D[scope]
D --> E[pre_run]
E --> F[callback]
F --> G[post_run]
end
style A fill:#e1f5fe
style B fill:#fff3e0
style C fill:#e8f5e9
style D fill:#fce4ec
style E fill:#f3e5f5
style F fill:#fffde7
style G fill:#f3e5f5
Your word only needs to implement the extension points relevant to its behavior.
Step-by-Step¶
- Create a struct (never instantiated — delete the constructor)
- Implement the desired extension points as static template methods
- Use your word in
on<>()like any built-in word
Example 1: LogTiming — Measuring Callback Duration¶
This word logs how long each callback takes to execute using pre_run and post_run:
#include "nuclear"
#include <chrono>
struct LogTiming {
LogTiming() = delete;
template <typename DSL>
static void pre_run(NUClear::threading::ReactionTask& task) {
// Store start time in the task's thread-local storage
start_time = std::chrono::steady_clock::now();
}
template <typename DSL>
static void post_run(NUClear::threading::ReactionTask& task) {
auto elapsed = std::chrono::steady_clock::now() - start_time;
auto us = std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
if (us > 1000) { // Only log if > 1ms
NUClear::log<NUClear::LogLevel::WARN>("Slow reaction:",
task.reaction->identifiers->name,
"took", us, "µs");
}
}
private:
static thread_local std::chrono::steady_clock::time_point start_time;
};
thread_local std::chrono::steady_clock::time_point LogTiming::start_time;
Usage:
on<Trigger<SensorData>, LogTiming>().then([](const SensorData& data) {
// If this takes too long, a warning is logged
heavy_computation(data);
});
Example 2: RateLimit — Limiting Reaction Frequency¶
This word prevents a reaction from firing more often than a specified rate, using precondition and a timer set up in bind:
#include "nuclear"
#include <chrono>
#include <mutex>
template <int MaxCount, typename Period = std::chrono::seconds>
struct RateLimit {
RateLimit() = delete;
template <typename DSL>
static void bind(const std::shared_ptr<NUClear::threading::Reaction>& reaction) {
// Initialize the token bucket for this reaction
auto& state = get_state(reaction->id);
state.tokens = MaxCount;
state.last_check = NUClear::clock::now();
}
template <typename DSL>
static bool precondition(NUClear::threading::ReactionTask& task) {
auto& state = get_state(task.reaction->id);
const std::lock_guard<std::mutex> lock(state.mutex);
// Replenish tokens based on elapsed time
auto now = NUClear::clock::now();
auto elapsed = std::chrono::duration_cast<Period>(now - state.last_check).count();
if (elapsed > 0) {
state.tokens = std::min(MaxCount, state.tokens + static_cast<int>(elapsed) * MaxCount);
state.last_check = now;
}
// Consume a token if available
if (state.tokens > 0) {
--state.tokens;
return true;
}
return false; // Task is dropped
}
private:
struct State {
std::mutex mutex;
int tokens{0};
NUClear::clock::time_point last_check;
};
static State& get_state(uint64_t reaction_id) {
static std::map<uint64_t, State> states;
static std::mutex map_mutex;
const std::lock_guard<std::mutex> lock(map_mutex);
return states[reaction_id];
}
};
Usage:
// Allow at most 10 executions per second
on<Trigger<HighFrequencyData>, RateLimit<10, std::chrono::seconds>>().then(
[](const HighFrequencyData& data) {
// Guaranteed to run at most 10 times per second
update_display(data);
});
Example 3: Composing Words with Fusion¶
NUClear's built-in Sync<T> is simply defined as inheriting from Group<T> — the Fusion Engine resolves inherited extension points.
You can compose existing words the same way:
// A word that combines Single (at most one active task) with a priority
template <typename T>
struct CriticalSingle : NUClear::dsl::word::Single,
NUClear::dsl::word::Priority::HIGH {
};
Usage:
on<Trigger<EmergencyAlert>, CriticalSingle<EmergencyHandler>>().then([](const EmergencyAlert& alert) {
// Runs at HIGH priority, never overlaps with itself
handle_emergency(alert);
});
The Fusion Engine walks the inheritance tree and collects all extension points from base classes.
Extension Point Summary¶
| Point | Purpose | Returns | Fusion Strategy |
|---|---|---|---|
bind |
Register reaction at creation time | void |
All called |
get |
Retrieve data for callback arguments | Any type | Tuple concatenation |
precondition |
Gate whether the task should run | bool |
Logical AND |
pre_run |
Hook before callback execution | void |
All called |
post_run |
Hook after callback execution | void |
All called |
scope |
RAII lock held during execution | RAII type | All held |
priority |
Task scheduling priority | int |
Maximum wins |
group |
Concurrency group membership | Set | Union |
pool |
Which thread pool to run on | Descriptor | (single value) |
See Extension Points Reference and Fusion Engine for full details.
Thread Context¶
Different extension points run in different thread contexts.
This is critical to understand when using thread_local storage or sharing state:
| Point | Runs on | Notes |
|---|---|---|
bind |
The thread that calls on<>() |
Usually the main thread during reactor construction |
get |
The thread that created the task | Often different from the execution thread |
precondition |
The thread that created the task | Same thread as get |
pre_run |
The execution thread | Same thread as the callback |
post_run |
The execution thread | Same thread as the callback |
scope |
The execution thread | RAII object lives for callback duration |
thread_local in get vs pre_run/post_run
Because `get` runs on the task-creation thread (not the execution thread), `thread_local` variables set in `get` will **not** be visible in `pre_run`, `post_run`, or the callback itself.
If you need per-execution state, use pre_run/post_run or the scope extension point, which provides RAII objects that persist for the lifetime of the reaction execution.
Tips¶
- Words are never instantiated — delete the constructor to make this clear.
- Use
thread_localstorage for per-execution state inpre_run/post_runonly — not inget. - Use the
scopeextension point if you need state that persists across the reaction execution with RAII semantics. - Template parameters on your word become compile-time configuration (like
RateLimit<10, seconds>). - Test custom words the same way you test any reactor — single-threaded plant with assertions.