![]() |
vkmEngine 1.0.0
A C++ game engine · vkmengine.com
|
Typed pub/sub dispatcher for in-engine events. Subscribers register a typed callback for events of type EventT; publishers call emit() to fire synchronously or enqueue() to defer until the next flush().
The bus is engine infrastructure, not a System: the Engine owns one by value (like the Clock and WindowManager), every FrameContext carries it as ctx.events, and flush() is called at the top of the Simulation stage - the fixed, visible point where queued events deliver. That point is reached once per tick, inside the fixed loop, and once more per frame for whatever was queued outside a tick or by the last tick of the frame; flush drains, so the second never repeats the first. Delivering on the frame instead would put a reaction however many ticks later the frame rate decided. Nothing is wired to it in setupEngineApp; systems just read it off the context.
Any user-defined struct is an event. No base class, no registration:
The type itself is the channel selector - subscribing to DamageEvent sees only DamageEvent instances.
An event type declared in gameplay code is the module's, and so is the bus the first subscribe to it creates - its vtable, its destructor and the events it has queued. The bus lives here, which outlives the module, so a script reload drops every bus nothing is listening on any more before the old library is unmapped (EventBus::dropIdleBuses, called by ScriptModule). A behavior's subscribe() is what makes that exact: it is tracked and dropped when the session ends, so by then the module's buses are the empty ones and the engine's still hold their systems' listeners. This is the reason a behavior's events() is an EventSender - emit and enqueue, no subscribe - so the tracked subscribe() is the only listener it can register; see Scripting.
subscribe returns a ListenerId for later removal. The callback runs on the frame thread, which is the main thread.
Returns true if the listener existed and was removed. Callable from inside a listener callback, including on itself: emit and flush walk by index, so mid-dispatch the entry is marked dead rather than erased, and reaped once the outermost dispatch unwinds. It is not emptied either: a listener unsubscribing itself is running inside the callable it holds, and clearing that would free the captures the rest of its callback runs on. A listener that subscribed during this same dispatch is waiting in the pending list and comes straight back out of it, so a one-shot can subscribe and cancel itself in one callback.
Two flavours:
Use emit for tightly coupled local state changes (gameplay reaction in the same tick). Use enqueue for decoupled cross-system flow (UI reactions, asset events, latency-tolerant work).
EventBus::flush(), called by Engine::run at the top of the Simulation stage, iterates every bus and drains its queue. Each enqueued event is delivered to every listener registered for its type.
A listener that enqueues a new event during flush will see it land on the next flush - the next tick's, or the frame's own when no tick is left - because every bus swaps its queue aside before any of them delivers, so re-entrant enqueues land in fresh storage whether they name the type being delivered or another one.
Main thread only. emit, enqueue, subscribe, unsubscribe, and flush must all happen on the same thread (typically the engine's update thread). Bus<EventT> holds no lock.
Everything above is what a project writes. What follows is how the engine answers it, for whoever maintains that half.
Internally each event type gets a lazily-created Bus<EventT> (stored in m_buses keyed by typeId<EventT>()). The bus holds:
emit and flush walk the listener vector by index, holding the size constant so subscribes during dispatch don't grow the iteration. The queue is swapped (not copied) at flush start.