Repository navigation
Event System
⚠️ This wiki is under construction. Pages are being populated incrementally.
Nodens routes messages through two independent paths, chosen by whether the message must respect a defined propagation order:
| Path | Carries | Transport | Ordering |
|---|---|---|---|
| Input events | Window and input messages produced by the platform layer (GLFW) | LayerStack |
Ordered — back-to-front, stops when consumed |
| Non-input events | Subsystem and user-defined events | EventBus |
Decoupled — immediate or queued per frame |
Both paths share a single event representation: a plain, trivially copyable struct with no base class, no virtual functions, and no RTTI. Type identity is a sequential integer, so dispatch never touches a vtable or a global type registry.
An event is a standard-layout, trivially copyable struct that exposes a
static constexpr std::string_view Name:
struct PlanetaryScanResultEvent
{
static constexpr std::string_view Name = "PlanetaryScanResult";
int Id{};
float Distance{};
float AtmosphereDensity{};
float CalculationTime{};
};The Event concept in src/Nodens/Events/Event.cppm enforces this shape:
template <typename TEvent>
concept Event = std::is_standard_layout_v<TEvent> && std::is_trivially_copyable_v<TEvent> &&
requires {
{ TEvent::Name } -> std::same_as<const std::string_view&>;
};Because every event is trivially copyable, it can be memcpy'd into a contiguous byte buffer —
this is what lets the EventBus queue an event without allocating per event. There is no Event
base class; event types never join an inheritance hierarchy. Concrete types assert the concept
themselves:
static_assert(Nodens::Event<PlanetaryScanResultEvent>);Event types are identified by Nodens::EventTypeID (a std::uint64_t), retrieved with
GetEventTypeId<TEvent>(). Each type receives the next integer from an atomic counter the first
time it is used:
using EventTypeID = std::uint64_t;
template <Event TEvent>
EventTypeID GetEventTypeId(); // sequential — assigned once, no RTTI, no hashingAn ID is stable for the life of the process, but its value depends on first-use order. Never
persist or hard-code an EventTypeID.
Input and window events originate from GLFW callbacks and form a closed set. Nodens::InputEvent
is a std::variant of every input type, defined in src/Nodens/Events/InputEvents.cppm under the
Nodens::InputEvents namespace:
-
Keyboard:
KeyPressed,KeyReleased,KeyTyped -
Mouse:
MouseMoved,MouseScrolled,MouseButtonPressed,MouseButtonReleased -
Window:
WindowResize,WindowClose
using InputEvent = std::variant<InputEvents::KeyPressed,
InputEvents::KeyReleased,
InputEvents::KeyTyped,
InputEvents::MouseMoved,
InputEvents::MouseScrolled,
InputEvents::MouseButtonPressed,
InputEvents::MouseButtonReleased,
InputEvents::WindowResize,
InputEvents::WindowClose>;The variant is carried through the framework inside a RoutedInputEvent, which adds a Handled
flag:
struct RoutedInputEvent
{
InputEvent Event; ///< The concrete event data.
bool Handled{}; ///< Set true to stop propagation.
};- The GLFW callbacks in
src/Platform/GLFW/GlfwWindow.cppbuild aRoutedInputEventand invoke the window'sInputEventCallbackFn— astd::function<void(RoutedInputEvent&)>thatApplicationinstalls at construction. -
Application::OnInputEventfirst dispatches the event to itself so thatWindowClosecan stop the main loop, then walks theLayerStackback-to-front, callingILayer::OnInputEvent(RoutedInputEvent&). - Iteration stops as soon as a layer sets
event.Handled = true.
Back-to-front order means overlays run before regular layers: an overlay such as ImGuiLayer sees
input first and can consume it before the game layers below.
flowchart TD
A["GLFW callback"] --> B["RoutedInputEvent"]
B --> C["Application::OnInputEvent"]
C --> D["WindowClose handled by Application"]
C --> E["Layers back-to-front: ILayer::OnInputEvent"]
E --> F{"Handled?"}
F -->|"yes"| G["stop propagation"]
F -->|"no"| E
InputEventDispatcher matches the variant's active alternative and runs a handler only when it
matches. Returning true from the handler marks the event handled.
void YourLayer::OnInputEvent(Nodens::RoutedInputEvent& e)
{
Nodens::InputEventDispatcher dispatcher(e);
dispatcher.Dispatch<Nodens::InputEvents::KeyPressed>(
[this](Nodens::InputEvents::KeyPressed& event)
{
Nodens::CoreLogger().info("Key {} (repeat {})", event.KeyCode, event.RepeatCount);
return true; // consume the event
});
}There are no category bitfields. Two constexpr helpers classify a variant by its alternatives:
IsMouseEvent(e); // MouseMoved, MouseScrolled, MouseButtonPressed, MouseButtonReleased
IsKeyboardEvent(e); // KeyPressed, KeyReleased, KeyTypedGetInputEventName(e) returns the active alternative's Name.
ImGuiLayer::OnInputEvent (src/Nodens/ImGui/ImGuiLayer.cpp) consumes the input ImGui wants:
e.Handled |= IsMouseEvent(e.Event) && io.WantCaptureMouse;
e.Handled |= IsKeyboardEvent(e.Event) && io.WantCaptureKeyboard;Because ImGuiLayer is an overlay, it runs first, so consumed events never reach the layers below
it. Blocking is gated by FApplicationSpecification::ShouldImGuiBlockInputs and toggled with
ImGuiLayer::BlockEvents.
Every message that is not a window/input event travels through EventBus. The bus is owned by the
Application and reached with Application::Get().GetEventBus() — it is not a global singleton.
using SubscriptionID = std::uint64_t;
class EventBus
{
template <Event TEvent, typename THandler>
requires std::invocable<THandler, TEvent&>
[[nodiscard]] SubscriptionID Subscribe(THandler&& handler);
void Unsubscribe(SubscriptionID id);
template <Event TEvent> void Dispatch(TEvent& event); // immediate
template <Event TEvent> void QueueEvent(TEvent event); // deferred, thread-safe
void Flush(); // called once per frame
};| Method | Call from | Behavior |
|---|---|---|
Subscribe<T>(handler) |
any thread | Registers a typed handler and returns a SubscriptionID. Registers the event type on first use. |
Unsubscribe(id) |
any thread | Removes a subscription. |
Dispatch<T>(event) |
main thread | Invokes every subscriber synchronously, in subscription order. |
QueueEvent<T>(event) |
any thread | Copies the event into the write buffer and returns immediately. |
Flush() |
main thread | Swaps the double buffers and dispatches everything queued since the last flush. |
-
Immediate (
Dispatch) suits events produced and consumed on the main thread. -
Deferred (
QueueEvent/Flush) suits events produced off-thread — typically from aJobSystemworker — that must be handled on the main thread.
QueueEvent copies the event's bytes into a per-type std::vector<std::byte> under a mutex.
Flush swaps the active and read buffers, then replays each span through a per-type dispatch
function that reconstructs the event and calls its subscribers. Handlers therefore always run on
the main thread. Application::Run calls Flush once per frame, before layers update.
examples/planetaryscan demonstrates the full deferred path. The layer subscribes in OnAttach:
void PlanetaryScanLayer::OnAttach()
{
Nodens::Application::Get().GetEventBus().Subscribe<PlanetaryScanResultEvent>(
[this](PlanetaryScanResultEvent& e) { AddResult(e); });
}A worker queued through the JobSystem publishes its result with QueueEvent, which is safe to
call off-thread:
Nodens::Application::Get().GetEventBus().QueueEvent<PlanetaryScanResultEvent>(e);The next Flush on the main thread delivers the event to AddResult. The SubscriptionID
returned by Subscribe can be stored and passed to Unsubscribe in OnDetach to release the
subscription.
flowchart TD
A["Worker: job finishes"] --> B["EventBus::QueueEvent(event)"]
B -->|"copies bytes, returns"| C["write buffer"]
C -->|"Flush swaps buffers"| D["EventBus::Flush() on main thread"]
D --> E["PlanetaryScanLayer::AddResult(event)"]
- Buffers grow on demand via
resize().EventBusdeclares a 64 KB default-capacity constant (kDefaultBufferCapacity), but the current implementation does not reserve it up front. - The subscriber registry (
m_Subscribers,m_DispatchTable,m_RegisteredEventsNames) is guarded bym_RegistryMutex; the queue buffers are guarded bym_BufferMutex. -
DispatchToSubscriberscopies the subscriber list before invoking handlers, so a handler may safely callSubscribeorUnsubscribe.
A formatter is optional. Events work fully without one — the variant, InputEventDispatcher, and
EventBus never format anything. You only need a formatter when you want to log an event or
pass it to std::format.
When you do need one, it lives outside the event struct as a std::formatter<T> specialization.
That keeps the event a pure data type while making it printable. Nodens' own input events declare
theirs this way in src/Nodens/Events/InputEvents.cppm.
Given an event struct — for instance the one from the PlanetaryScan example:
struct PlanetaryScanResultEvent
{
static constexpr std::string_view Name = "PlanetaryScanResult";
int Id{};
float Distance{};
float AtmosphereDensity{};
float CalculationTime{};
};add a std::formatter specialization for it, right after the struct:
template <>
struct std::formatter<PlanetaryScanResultEvent> : std::formatter<std::string_view>
{
template <typename FormatContext>
auto format(const PlanetaryScanResultEvent& e, FormatContext& ctx) const
{
return std::format_to(ctx.out(),
"PlanetaryScanResult(id={}, distance={:.2f} ly, density={:.3f})",
e.Id, e.Distance, e.AtmosphereDensity);
}
};Points to follow:
-
Specialize in namespace
std. This is the only place the language allows specializingstd::formatter, and it is permitted becausePlanetaryScanResultEventis a program-defined type. Never add aformatmember to the event struct itself. -
Derive from
std::formatter<std::string_view>. You inherit aparse()that accepts{}, so you never write a format-spec parser. Every formatter inInputEvents.cppmdoes the same. -
Write with
std::format_to(ctx.out(), ...)and return its iterator. Build the text from the event's fields and reuse the built-in formatters for them (e.g.{:.2f},{:.3f}). -
Declare it in the same module as the event struct, immediately after it. Importers of that
module can then format the event; the specialization does not need to be
exported. -
Keep the spec empty (
{}). Becauseformat()writes straight toctx.out(), any width/alignment spec such as{:>12}is parsed but not applied.
Once declared, the event formats anywhere:
PlanetaryScanResultEvent e{.Id = 7, .Distance = 12.5f, .AtmosphereDensity = 0.62f};
Nodens::ClientLogger().info("scan complete: {}", e); // your formatter runs here
std::string line = std::format("{} -> {}", PlanetaryScanResultEvent::Name, e);The bundled PlanetaryScan example (examples/planetaryscan) defines exactly this formatter and logs
with it in PlanetaryScanLayer::AddResult.
Nodens builds spdlog with SPDLOG_USE_STD_FORMAT (CMakeLists.txt), so CoreLogger() /
ClientLogger() consume these standard std::formatter specializations directly — there is no
separate fmt specialization to provide.
- Layer System — How input events are routed through layers.
-
Concurrency and JobSystem — The worker threads that feed
QueueEvent. - Examples Guide — The PlanetaryScan example in full.
-
Event,InputEventDispatcher,EventBus,RoutedInputEventin the API Reference.
Getting Started
Architecture
Guides
Reference