SignalBus C++ Usage
Review draft. This source page has not completed website editorial and release-specific verification.
SignalBus lets C++ systems publish and receive local messages without storing direct references to every other system that may react.
The basic flow is:
Publisher -> SignalBus subsystem -> Subscriber callbackA message is routed only when all three values match:
ScopeMessage IDExact payload struct typeSignalBus does not create:
- A global process-wide bus
- Automatic Actor discovery
- Automatic server-to-client delivery
- Automatic replication
- Cross-thread callbacks
- Persistent message history for replay
Quick Start
Section titled “Quick Start”A typical C++ setup has five parts:
- Define a reflected payload struct.
- Create a stable Message ID.
- Get the correct SignalBus subsystem.
- Subscribe and store the returned handle.
- Publish with the same Message ID and exact payload type.
1. Define a Payload Struct
Section titled “1. Define a Payload Struct”SignalBus payloads must be reflected Unreal structs.
USTRUCT(BlueprintType)struct FQuestSignal{ GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite) FName QuestId = NAME_None;
UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 Step = 0;};Use small structs that describe an event.
Good payloads:
FHealthChangedSignalFDoorOpenedSignalFQuestAdvancedSignalFSaveCompletedSignalAvoid using SignalBus as a bulk data transport for:
- Large arrays
- Entire inventories
- Save-game blobs
- Large strings
- Full UObject state
- Asset data
A better pattern is to pass identifiers or a small summary, then let the subscriber query durable state from its owning system.
Example:
USTRUCT()struct FInventoryChangedSignal{ GENERATED_BODY()
UPROPERTY() FGuid InventoryId;
UPROPERTY() int32 ChangedSlot = INDEX_NONE;};2. Create a Message ID
Section titled “2. Create a Message ID”Create a stable Message ID using a clear name:
const FSignalBusMessageId QuestAdvanced(TEXT("Quest.Advanced"));Recommended naming style:
Category.EventNameExamples:
Combat.HealthChangedQuest.AdvancedInventory.ItemAddedWorld.DoorOpenedSave.CompletedTravel.StartedDefault-constructed Message IDs are invalid:
FSignalBusMessageId InvalidMessageId;Avoid vague names such as:
UpdateEventChangedMessage1Clear names make diagnostics and the Broadcast Graph easier to understand.
A useful project rule is:
One Message IDOne expected payload structOne intended scopeSignalBus can technically route the same Message ID with different payload types, but doing so makes debugging and maintenance harder.
3. Choose the Correct Scope
Section titled “3. Choose the Correct Scope”SignalBus provides two local scopes.
World scope
Section titled “World scope”Use:
USignalBusWorldSubsystemfor messages tied to the current gameplay World, level, or match.
Examples:
Combat.DamageAppliedEnemy.SpawnedDoor.OpenedObjective.ProgressChangedProjectile.ImpactGet the subsystem:
UWorld* World = GetWorld();if (!IsValid(World)){ return;}
USignalBusWorldSubsystem* Bus = World->GetSubsystem<USignalBusWorldSubsystem>();
if (!IsValid(Bus)){ return;}Game Instance scope
Section titled “Game Instance scope”Use:
USignalBusGameInstanceSubsystemfor local session-level messages that may survive map travel.
Examples:
Profile.LoadedSave.CompletedLogin.StatusChangedMatchmaking.SearchStartedTravel.StartedTravel.CompletedGet the subsystem:
UGameInstance* GameInstance = GetGameInstance();if (!IsValid(GameInstance)){ return;}
USignalBusGameInstanceSubsystem* Bus = GameInstance->GetSubsystem<USignalBusGameInstanceSubsystem>();
if (!IsValid(Bus)){ return;}Simple rule
Section titled “Simple rule”Current map, match, or gameplay World -> World bus
Local session or cross-level coordination -> Game Instance busThe two scopes are separate. A World publish does not reach a Game Instance subscriber.
4. Subscribe
Section titled “4. Subscribe”World subscription
Section titled “World subscription”const FSignalBusMessageId QuestAdvanced(TEXT("Quest.Advanced"));
UWorld* World = GetWorld();if (!IsValid(World)){ return;}
USignalBusWorldSubsystem* Bus = World->GetSubsystem<USignalBusWorldSubsystem>();
if (!IsValid(Bus)){ return;}
FSignalBusSubscriptionHandle QuestSubscription = Bus->Subscribe<FQuestSignal>( QuestAdvanced, this, FSignalBusMessageCallback::CreateLambda( [](const void* PayloadData) { const FQuestSignal& Payload = *static_cast<const FQuestSignal*>(PayloadData);
UE_LOG( LogTemp, Log, TEXT("Quest %s advanced to step %d"), *Payload.QuestId.ToString(), Payload.Step); }));The subscription matches only:
World scopeQuest.AdvancedFQuestSignalGame Instance subscription
Section titled “Game Instance subscription”UGameInstance* GameInstance = GetGameInstance();if (!IsValid(GameInstance)){ return;}
USignalBusGameInstanceSubsystem* Bus = GameInstance->GetSubsystem<USignalBusGameInstanceSubsystem>();
if (!IsValid(Bus)){ return;}
FSignalBusSubscriptionHandle SaveSubscription = Bus->Subscribe<FSaveCompletedSignal>( FSignalBusMessageId(TEXT("Save.Completed")), this, SaveCompletedCallback);Lifetime Owner
Section titled “Lifetime Owner”The Owner passed to Subscribe is stored weakly.
Example:
Bus->Subscribe<FQuestSignal>( QuestAdvanced, this, Callback);SignalBus does not keep this alive.
When the owner expires:
- The callback is skipped
- The subscription is pruned during dispatch or cleanup
- The weak lifetime relationship prevents accidental object retention
This is useful for Actors and Components because destroying the object does not leave a strong reference cycle.
However, the handle is still your responsibility.
The handle does not automatically unsubscribe merely because the local handle variable goes out of scope.
Store the Subscription Handle
Section titled “Store the Subscription Handle”Store the returned handle as a member when you need explicit cleanup.
private: FSignalBusSubscriptionHandle QuestSubscription;Then assign it:
QuestSubscription = Bus->Subscribe<FQuestSignal>( QuestAdvanced, this, Callback);A handle is a passive value.
It does not:
- Keep the subscriber alive
- Keep the subsystem alive
- Keep the publisher alive
- Automatically unsubscribe
- Transfer ownership
Do not manually construct or modify handle internals.
Unsubscribe
Section titled “Unsubscribe”Use the same subsystem that created the handle.
if (QuestSubscription.IsValid()){ if (USignalBusWorldSubsystem* Bus = GetWorld()->GetSubsystem<USignalBusWorldSubsystem>()) { const bool bRemoved = Bus->Unsubscribe(QuestSubscription);
if (bRemoved) { QuestSubscription.Reset(); } }}A simpler pattern:
const bool bRemoved = Bus->Unsubscribe(QuestSubscription);QuestSubscription.Reset();Repeated unsubscribe attempts should be treated as a failed removal or no-op.
Unsubscribing from the wrong subsystem does not remove the original subscription.
Recommended Actor Lifecycle
Section titled “Recommended Actor Lifecycle”For an Actor or Component:
void UMyComponent::BeginPlay(){ Super::BeginPlay();
SubscribeToSignals();}
void UMyComponent::EndPlay( const EEndPlayReason::Type EndPlayReason){ UnsubscribeFromSignals();
Super::EndPlay(EndPlayReason);}Example:
void UMyComponent::SubscribeToSignals(){ UWorld* World = GetWorld(); if (!IsValid(World)) { return; }
USignalBusWorldSubsystem* Bus = World->GetSubsystem<USignalBusWorldSubsystem>();
if (!IsValid(Bus)) { return; }
QuestSubscription = Bus->Subscribe<FQuestSignal>( FSignalBusMessageId(TEXT("Quest.Advanced")), this, FSignalBusMessageCallback::CreateUObject( this, &UMyComponent::HandleQuestAdvanced));}
void UMyComponent::UnsubscribeFromSignals(){ if (!QuestSubscription.IsValid()) { return; }
UWorld* World = GetWorld(); if (IsValid(World)) { if (USignalBusWorldSubsystem* Bus = World->GetSubsystem<USignalBusWorldSubsystem>()) { Bus->Unsubscribe(QuestSubscription); } }
QuestSubscription.Reset();}Avoid subscribing in constructors. Unreal object construction may occur before a valid World or Game Instance exists.
Callback Styles
Section titled “Callback Styles”Use the callback binding style that matches your object lifetime and ownership.
UObject member callback
Section titled “UObject member callback”FSignalBusMessageCallback::CreateUObject( this, &UMyComponent::HandleQuestAdvanced)Example handler:
void UMyComponent::HandleQuestAdvanced(const void* PayloadData){ const FQuestSignal& Payload = *static_cast<const FQuestSignal*>(PayloadData);
// React to the message.}Lambda callback
Section titled “Lambda callback”FSignalBusMessageCallback::CreateLambda( [](const void* PayloadData) { const FQuestSignal& Payload = *static_cast<const FQuestSignal*>(PayloadData); })Be careful with lambda captures.
Unsafe:
FSignalBusMessageCallback::CreateLambda( [this](const void* PayloadData) { // Unsafe if this is destroyed and the callback outlives it. })Safer approaches:
- Use
CreateUObject - Capture a
TWeakObjectPtr - Use the same UObject as the lifetime owner
- Unsubscribe explicitly during teardown
Example:
TWeakObjectPtr<UMyComponent> WeakThis(this);
FSignalBusMessageCallback Callback = FSignalBusMessageCallback::CreateLambda( [WeakThis](const void* PayloadData) { if (!WeakThis.IsValid()) { return; }
const FQuestSignal& Payload = *static_cast<const FQuestSignal*>(PayloadData);
WeakThis->HandleQuest(Payload); });Immediate Publish
Section titled “Immediate Publish”Immediate publish is synchronous.
FQuestSignal Payload;Payload.QuestId = TEXT("Intro");Payload.Step = 2;
FSignalBusDispatchResult Result = Bus->PublishImmediate( QuestAdvanced, Payload, this);The optional publisher object is diagnostic context only.
It does not affect routing.
Expected order:
Before PublishSubscriber CallbackAfter PublishExample:
UE_LOG(LogTemp, Log, TEXT("Before Publish"));
const FSignalBusDispatchResult Result = Bus->PublishImmediate( QuestAdvanced, Payload, this);
UE_LOG(LogTemp, Log, TEXT("After Publish"));Subscriber output appears between those two log entries.
Use Immediate when local subscribers must react before the publishing function continues.
Good uses:
- Updating dependent local state immediately
- Synchronous gameplay notifications
- Small fan-out operations
- Events where callback order matters
Be cautious with expensive callbacks because all subscriber work occurs before PublishImmediate returns.
Queued Publish
Section titled “Queued Publish”Queued publish copies the payload and returns before subscriber callbacks run.
FSignalBusQueueResult QueueResult = Bus->PublishQueued( QuestAdvanced, Payload, this);Expected order:
Before QueueAfter QueueSubscriber CallbackUse Queued when:
- The publisher should finish first
- Work can happen later
- You want FIFO deferred delivery
- You want to avoid deep immediate reentrant chains
Queued publish is not a timer.
It means delivery occurs during a later SignalBus queue-processing pass.
It does not mean:
Deliver after exactly 5 secondsUse FTimerManager or another scheduler for timed delivery.
Publish Results
Section titled “Publish Results”Always inspect publish results when failure matters.
Immediate result
Section titled “Immediate result”const FSignalBusDispatchResult Result = Bus->PublishImmediate( MessageId, Payload, this);Use the result to determine whether:
- Validation succeeded
- Subscribers matched
- Subscribers were invoked
- Expired subscribers were skipped
- Payload mismatches occurred
- A failure reason was reported
Use the actual fields exposed by your current FSignalBusDispatchResult implementation.
Queue result
Section titled “Queue result”const FSignalBusQueueResult QueueResult = Bus->PublishQueued( MessageId, Payload, this);Check whether:
- The queue accepted the message
- Queue capacity was exceeded
- Validation failed
- The queue entry received a valid ID
Do not assume every queue request succeeds.
SignalBus 1.0.0 uses bounded queue capacity.
Example Scenario 1: Door Opened
Section titled “Example Scenario 1: Door Opened”Payload
Section titled “Payload”USTRUCT()struct FDoorOpenedSignal{ GENERATED_BODY()
UPROPERTY() FName DoorId = NAME_None;
UPROPERTY() TWeakObjectPtr<AActor> Instigator;};Message ID
Section titled “Message ID”const FSignalBusMessageId DoorOpened( TEXT("World.DoorOpened"));Publisher
Section titled “Publisher”FDoorOpenedSignal Payload;Payload.DoorId = DoorId;Payload.Instigator = OpeningActor;
Bus->PublishImmediate( DoorOpened, Payload, this);Subscriber
Section titled “Subscriber”DoorSubscription = Bus->Subscribe<FDoorOpenedSignal>( DoorOpened, this, FSignalBusMessageCallback::CreateUObject( this, &UQuestComponent::HandleDoorOpened));Why World scope?
The door belongs to the current gameplay World.
Example Scenario 2: Save Completed
Section titled “Example Scenario 2: Save Completed”Payload
Section titled “Payload”USTRUCT()struct FSaveCompletedSignal{ GENERATED_BODY()
UPROPERTY() FName SlotName = NAME_None;
UPROPERTY() bool bSuccess = false;
UPROPERTY() FString ErrorMessage;};Message ID
Section titled “Message ID”const FSignalBusMessageId SaveCompleted( TEXT("Save.Completed"));Publisher
Section titled “Publisher”USignalBusGameInstanceSubsystem* Bus = GetGameInstance()->GetSubsystem< USignalBusGameInstanceSubsystem>();
FSaveCompletedSignal Payload;Payload.SlotName = SlotName;Payload.bSuccess = bSuccess;Payload.ErrorMessage = ErrorMessage;
Bus->PublishImmediate( SaveCompleted, Payload, this);Why Game Instance scope?
Save completion belongs to the local session and may remain relevant across map travel.
Example Scenario 3: Client Reaction to Replication
Section titled “Example Scenario 3: Client Reaction to Replication”SignalBus does not replicate messages.
A safe multiplayer pattern is:
Server updates authoritative state -> state replicates to client -> client OnRep publishes local SignalBus message -> local client systems reactExample:
void UHealthComponent::OnRep_Health( float PreviousHealth){ UWorld* World = GetWorld(); if (!IsValid(World)) { return; }
USignalBusWorldSubsystem* Bus = World->GetSubsystem<USignalBusWorldSubsystem>();
if (!IsValid(Bus)) { return; }
FHealthChangedSignal Payload; Payload.OldHealth = PreviousHealth; Payload.NewHealth = Health;
Bus->PublishImmediate( FSignalBusMessageId(TEXT("Combat.HealthChanged")), Payload, this);}Local subscribers may include:
- HUD
- Audio
- Camera effects
- Accessibility feedback
The server SignalBus publish itself does not travel to clients.
Example Scenario 4: Travel Coordination
Section titled “Example Scenario 4: Travel Coordination”Use Game Instance scope for travel events.
FTravelStartedSignal Payload;Payload.DestinationMap = DestinationMap;
GameInstanceBus->PublishImmediate( FSignalBusMessageId(TEXT("Travel.Started")), Payload, this);Subscribers may include:
- Loading screen coordinator
- Save manager
- Session manager
- Frontend flow
Do not queue a World message and expect it to survive destruction of the old World.
Exact Type Matching
Section titled “Exact Type Matching”SignalBus uses exact reflected struct identity.
The Message ID and payload struct type must both match.
These do not match:
Publisher:Quest.AdvancedFQuestSignal
Subscriber:Quest.AdvancedFQuestSignalExtendedEven if the fields are similar, SignalBus does not perform:
- Inheritance matching
- Base-struct matching
- Field-by-field conversion
- Implicit conversion
- Serialization-based matching
Always cast callback payload data to the exact type used in the template subscription.
Correct:
Bus->Subscribe<FQuestSignal>(...);
const FQuestSignal& Payload = *static_cast<const FQuestSignal*>(PayloadData);Incorrect:
Bus->Subscribe<FQuestSignal>(...);
const FOtherSignal& Payload = *static_cast<const FOtherSignal*>(PayloadData);The second example is unsafe and invalid.
Scope Isolation
Section titled “Scope Isolation”World and Game Instance buses are separate.
This does not deliver:
Publisher:World busQuest.AdvancedFQuestSignal
Subscriber:Game Instance busQuest.AdvancedFQuestSignalThe Message ID and payload type match, but the scope does not.
Likewise, different PIE contexts and network peers have separate buses.
SignalBus does not automatically route between:
- Server and client
- Client and server
- Client 1 and Client 2
- PIE 0 and PIE 1
- One Game Instance and another
Use Unreal replication or RPCs for network communication.
Common Pitfalls
Section titled “Common Pitfalls”1. Using the wrong subsystem
Section titled “1. Using the wrong subsystem”Publishing through the World bus while subscribing through the Game Instance bus produces no delivery.
Fix:
Use the same scope on both sides.2. Failing to validate the subsystem
Section titled “2. Failing to validate the subsystem”Unsafe:
GetWorld() ->GetSubsystem<USignalBusWorldSubsystem>() ->PublishImmediate(...);If GetWorld() is null or the subsystem is unavailable during teardown, this can crash.
Safer:
UWorld* World = GetWorld();if (!IsValid(World)){ return;}
USignalBusWorldSubsystem* Bus = World->GetSubsystem<USignalBusWorldSubsystem>();
if (!IsValid(Bus)){ return;}3. Subscribing in a constructor
Section titled “3. Subscribing in a constructor”Constructors may run without a valid World or Game Instance.
Subscribe during an appropriate lifecycle phase:
BeginPlayInitializeOnWorldBeginPlayGame Instance subsystem initialization4. Forgetting to store the handle
Section titled “4. Forgetting to store the handle”If you need explicit cleanup, store the handle.
Bad:
Bus->Subscribe<FQuestSignal>(...);Better:
QuestSubscription = Bus->Subscribe<FQuestSignal>(...);The weak lifetime owner still prevents strong retention, but explicit cleanup is clearer and removes stale registrations sooner.
5. Capturing raw this in a lambda
Section titled “5. Capturing raw this in a lambda”A lambda may outlive the object it captured.
Prefer:
CreateUObjector capture a TWeakObjectPtr.
6. Subscribing multiple times accidentally
Section titled “6. Subscribing multiple times accidentally”Calling Subscribe repeatedly creates multiple valid subscriptions.
One publish may invoke the same logical listener multiple times.
Do not subscribe every Tick or every refresh.
Subscribe once during a defined lifecycle event.
7. Publishing every Tick
Section titled “7. Publishing every Tick”High-frequency publishing can create:
- Heavy callback volume
- Large diagnostic history
- Warning activity
- Queue backlog
- Editor slowdown
Publish when meaningful state changes.
Bad:
void Tick(...){ Bus->PublishImmediate( HealthChanged, Payload, this);}Better:
if (!FMath::IsNearlyEqual(OldHealth, NewHealth)){ Bus->PublishImmediate( HealthChanged, Payload, this);}8. Treating queued delivery as scheduling
Section titled “8. Treating queued delivery as scheduling”Queued delivery is deferred, not time-based.
Use:
FTimerManagerfor delivery after a chosen delay.
9. Expecting replay
Section titled “9. Expecting replay”SignalBus does not replay old messages to new subscribers.
Pattern:
Read current durable stateThen subscribe for future changesExample:
RefreshFromCurrentQuestState();SubscribeToQuestSignals();10. Assuming the publisher controls routing
Section titled “10. Assuming the publisher controls routing”The optional Publisher object is diagnostic context only.
Subscribers are selected by:
ScopeMessage IDPayload typeIf a subscriber should react only to one source, include a source identifier in the payload and filter inside the callback.
11. Publishing from a worker thread
Section titled “11. Publishing from a worker thread”SignalBus is game-thread-only.
Do not publish directly from:
- Thread pool tasks
- Background file loading
- External SDK worker callbacks
- Custom threads
Return to the game thread first:
AsyncTask( ENamedThreads::GameThread, [WeakThis, Payload]() { if (!WeakThis.IsValid()) { return; }
WeakThis->PublishPayload(Payload); });12. Using large payloads
Section titled “12. Using large payloads”Queued delivery copies payloads.
Blueprint listeners also receive safe payload copies.
Large payloads increase:
- Copy cost
- Memory use
- Queue pressure
- Diagnostic snapshot cost
Prefer small message contracts.
Reentrancy and Nested Publishing
Section titled “Reentrancy and Nested Publishing”Immediate subscribers may publish another immediate message.
Example:
Publish A -> Subscriber A runs -> Publish B -> Subscriber B runs -> Publish A returnsThis is supported, but deep cycles can become difficult to reason about.
Avoid accidental loops such as:
Message A subscriber publishes Message BMessage B subscriber publishes Message AUse:
- State guards
- Generation counters
- Queued delivery
- Explicit cycle detection in higher-level logic
SignalBus does not automatically prevent logical message cycles.
Queued Message Behavior
Section titled “Queued Message Behavior”Queued messages are copied when accepted.
Subscribers are matched later during delivery.
This means:
Queue messageSubscriber registers before delivery -> subscriber may receive itand:
Queue messageSubscriber unsubscribes before delivery -> subscriber does not receive itA destroyed publisher does not automatically cancel a queued message.
The Publisher identity is diagnostic context, not queue ownership.
Diagnostics
Section titled “Diagnostics”C++ publishes and subscriptions appear in:
- SignalBus Runtime Inspector
- Active Subscriptions
- Pending Queue
- Event History
- Payload inspection
- Warnings
- Broadcast Graph
- Unreal Trace
Use diagnostics when a message is not received.
Check:
- Was the correct subsystem used?
- Was the subscription active?
- Did the Message ID match exactly?
- Did the payload type match exactly?
- Was the correct PIE context selected?
- Was the publish rejected?
- Was the queue full?
- Was the owner destroyed?
- Was the subscription removed?
- Was delivery Immediate or Queued?
The Publisher parameter improves diagnostic identity but does not affect delivery.
Performance Guidance
Section titled “Performance Guidance”Prefer:
- Small payload structs
- Event-based publishing
- One subscription per intended listener
CreateUObjectfor UObject callbacks- Explicit handle cleanup
- Queued delivery for non-urgent work
- Immediate delivery only when synchronous behavior is required
Avoid:
- Publishing every Tick
- Repeated duplicate subscriptions
- Deep nested immediate publish chains
- Large payload containers
- Long-running subscriber callbacks
- Treating SignalBus as durable storage
- Treating SignalBus as replication
Recommended Wrapper Pattern
Section titled “Recommended Wrapper Pattern”For frequently used messages, centralize IDs and payload contracts.
Example:
namespace QuestSignals{ inline const FSignalBusMessageId Advanced( TEXT("Quest.Advanced"));}Then use:
Bus->PublishImmediate( QuestSignals::Advanced, Payload, this);This reduces spelling errors.
For larger projects, place message IDs near the payload contract or in a dedicated signal-contract header.
Do not create a mutable global routing registry.
Testing Guidance
Section titled “Testing Guidance”For automated tests, validate:
- Subscription handle validity
- Exact callback count
- Callback order
- Immediate synchronous behavior
- Queued deferred behavior
- FIFO order
- Unsubscribe behavior
- Owner destruction
- Scope isolation
- Exact payload matching
- Queue capacity handling
- Reentrant publishing where relevant
A simple immediate-order assertion is:
BeforeCallbackAfterA queued-order assertion is:
BeforeAfterCallbackFinal Checklist
Section titled “Final Checklist”For every C++ SignalBus flow, confirm:
Valid World or Game InstanceCorrect subsystemStable Message IDReflected payload structExact payload type matchStored subscription handleWeak lifetime ownerSafe callback bindingExplicit cleanup where appropriateIntentional Immediate or Queued modeGame-thread executionNetworking handled separatelyThe simplest reliable pattern is:
Define a small USTRUCTCreate one stable Message IDSubscribe onceStore the handlePublish only when state changesUnsubscribe during teardown