Skip to content

Event System

renan bomtempo edited this page Sep 24, 2026 · 3 revisions

⚠️ This wiki is under construction. Pages are being populated incrementally.

Event System

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.

Events are plain data

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>);

Type identification

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 hashing

An 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 events

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.
};

Propagation

  1. The GLFW callbacks in src/Platform/GLFW/GlfwWindow.cpp build a RoutedInputEvent and invoke the window's InputEventCallbackFn — a std::function<void(RoutedInputEvent&)> that Application installs at construction.
  2. Application::OnInputEvent first dispatches the event to itself so that WindowClose can stop the main loop, then walks the LayerStack back-to-front, calling ILayer::OnInputEvent(RoutedInputEvent&).
  3. 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
Loading

Dispatching within a layer

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
        });
}

Classifying input events

There are no category bitfields. Two constexpr helpers classify a variant by its alternatives:

IsMouseEvent(e);     // MouseMoved, MouseScrolled, MouseButtonPressed, MouseButtonReleased
IsKeyboardEvent(e);  // KeyPressed, KeyReleased, KeyTyped

GetInputEventName(e) returns the active alternative's Name.

ImGui input blocking

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.

Non-input events — the EventBus

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 vs. deferred

  • Immediate (Dispatch) suits events produced and consumed on the main thread.
  • Deferred (QueueEvent / Flush) suits events produced off-thread — typically from a JobSystem worker — 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.

Example: the PlanetaryScan app

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)"]
Loading

Implementation notes

  • Buffers grow on demand via resize(). EventBus declares 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 by m_RegistryMutex; the queue buffers are guarded by m_BufferMutex.
  • DispatchToSubscribers copies the subscriber list before invoking handlers, so a handler may safely call Subscribe or Unsubscribe.

Formatting and logging events

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.

Adding a formatter for your own event

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 specializing std::formatter, and it is permitted because PlanetaryScanResultEvent is a program-defined type. Never add a format member to the event struct itself.
  • Derive from std::formatter<std::string_view>. You inherit a parse() that accepts {}, so you never write a format-spec parser. Every formatter in InputEvents.cppm does 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 ({}). Because format() writes straight to ctx.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.

See Also

  • 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, RoutedInputEvent in the API Reference.

Clone this wiki locally