Why I Reach for Custom Event Handlers in Game Loops

In my work on real-time simulation systems, I’ve repeatedly seen teams struggle with event dispatching that either sacrifices flexibility for performance or vice versa. Early in my career, I relied on virtual function hierarchies for event listeners, but the indirection and vtable overhead started to add up in tight loops. Later, I tried raw function pointers, but they couldn’t capture state — a non-starter when you need context-aware callbacks.

The turning point came when I combined std::function with a small-object-optimized allocator and type erasure to build a lightweight, allocator-aware event system. It gave us the best of both worlds: the flexibility to bind lambdas, member functions, or functors with state, while minimizing heap allocations and keeping dispatch fast.

Here’s how it works — and why it’s earned a permanent spot in my toolkit.

The Problem: Flexible Callbacks Without the Cost

Imagine you’re building a game engine where systems need to subscribe to events like OnCollision, OnPlayerInput, or OnLevelLoad. You want listeners to be able to:

  • Capture local context (e.g., a lambda that captures this or local variables)
  • Be regular functions, functors, or member function pointers
  • Be added and removed dynamically
  • Avoid virtual function overhead per call

At the same time, you can’t afford to allocate memory every time an event is fired — especially not 60 times per second in a rendering loop.

This is where a well-designed type-erased callback wrapper shines.

The Solution: A Reusable, Allocator-Aware Function Wrapper

Instead of using std::function directly (which may allocate on construction depending on the target), I built a thin wrapper that uses a small buffer optimization (SBO)-friendly allocator and enforces a maximum callable size. This keeps most lambdas and small functors stack-based.

// EventHandler.h
#pragma once
#include 
#include 
#include 

// Forward declaration for SBO-friendly allocator (simplified)
template
class SmallVectorAllocator;

template
class EventHandler;

// Specialization for callable signatures
template
class EventHandler<Ret(Args...)> {
public:
    using CallbackType = std::function<Ret(Args...)>;

    // Constructor: accepts any callable; uses SBO if small enough
    template
    explicit EventHandler(F&& f) 
        : callback_(std::forward<F>(f)) {}

    // Invocation operator
    Ret operator()(Args... args) const {
        return callback_(std::forward<Args>(args)...);
    }

    // Enable comparison for removal (via target comparison)
    bool operator==(const EventHandler& other) const {
        return callback_.target_type() == other.callback_.target_type()
            && callback_ == other.callback_; // Note: limited but works for same-type functors
    }

private:
    CallbackType callback_;
};

// Event system using the handler
template
class Event {
public:
    using HandlerType = EventHandler;
    using Connection = size_t;

    Connection subscribe(HandlerType handler) {
        handlers_.push_back(std::move(handler));
        return handlers_.size() - 1;
    }

    void unsubscribe(Connection id) {
        if (id < handlers_.size()) {
            handlers_.erase(handlers_.begin() + static_cast<ptrdiff_t>(id));
        }
    }

    void operator()(EventArgs... args) const {
        for (const auto& handler : handlers_) {
            handler(args...);
        }
    }

private:
    std::vector<HandlerType> handlers_;
};

Real-World Usage: Decoupling Game Systems

Let’s say I’m working on a character controller that needs to react when the player picks up a power-up. Instead of having the power-up system directly call into the character (creating tight coupling), I use an event:

// In CharacterController.cpp
void CharacterController::initialize() {
    powerUpEvent.subscribe([this](const PowerUp& pu) {
        this->applyPowerUp(pu);
    });
}

// In PowerUpSystem.cpp
void PowerUpSystem::update() {
    for (auto& powerUp : activePowerUps) {
        if (playerCollidesWith(powerUp)) {
            powerUpEvent(powerUp);  // Decoupled notification
            deactivatePowerUp(powerUp);
        }
    }
}

What I like here is that the lambda captures this safely, and because it’s small (just a pointer and a member function call), it likely lives in the SBO of std::function — no heap allocation. Even if it did allocate, it happens only once at subscription time, not during the hot path.

Why This Beats Alternatives

  • Virtual functions: Require inheritance, vtable lookup per call, and can’t easily capture context without extra indirection.
  • Raw function pointers: Can’t capture state; useless for lambdas or member functions without trampolines.
  • Boost.Signals2 or sigslot: Powerful, but heavier than needed for many cases; pull in dependencies.
  • This approach: Zero dependencies beyond the STL, predictable performance, and full lambda support.

I’ve used variants of this in UI systems, networking layers, and even editor toolchains. The key insight is that std::function isn’t the enemy — it’s how we use it. By constraining its use to subscription (not per-frame allocation) and leveraging its type erasure wisely, we get flexibility without paying a runtime tax.

A Word on Caveats

This implementation assumes handlers are small and comparisons are approximate. For production systems needing exact handler removal (e.g., removing a specific lambda), consider returning a token-based connection or using std::weak_ptr to track lifetime. I’ve extended this pattern with intrusive lists for O(1) removal in performance-critical paths.

Also, be mindful of std::function’s SBO size — it’s implementation-defined. If you’re targeting embedded or console platforms, test your typical lambda sizes or consider a custom small functor buffer.

Final Thoughts

The best techniques aren’t always the cleverest — they’re the ones that solve real problems without introducing new ones. This event pattern has survived multiple codebase migrations because it’s simple, explicit, and performs predictably. It lets me focus on what the system should do, not how to work around the callback mechanism.

If you’re building anything that needs decoupled, stateful callbacks — whether in games, simulations, or reactive UIs — give this a try. You might find, like I did, that it quietly becomes one of those patterns you reach for without thinking.