Skip to content
Tuxedo CatSTUDIO
Menu

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 callback

A message is routed only when all three values match:

Scope
Message ID
Exact payload struct type

SignalBus 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

A typical C++ setup has five parts:

  1. Define a reflected payload struct.
  2. Create a stable Message ID.
  3. Get the correct SignalBus subsystem.
  4. Subscribe and store the returned handle.
  5. Publish with the same Message ID and exact payload type.

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:

FHealthChangedSignal
FDoorOpenedSignal
FQuestAdvancedSignal
FSaveCompletedSignal

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

Create a stable Message ID using a clear name:

const FSignalBusMessageId QuestAdvanced(TEXT("Quest.Advanced"));

Recommended naming style:

Category.EventName

Examples:

Combat.HealthChanged
Quest.Advanced
Inventory.ItemAdded
World.DoorOpened
Save.Completed
Travel.Started

Default-constructed Message IDs are invalid:

FSignalBusMessageId InvalidMessageId;

Avoid vague names such as:

Update
Event
Changed
Message1

Clear names make diagnostics and the Broadcast Graph easier to understand.

A useful project rule is:

One Message ID
One expected payload struct
One intended scope

SignalBus can technically route the same Message ID with different payload types, but doing so makes debugging and maintenance harder.


SignalBus provides two local scopes.

Use:

USignalBusWorldSubsystem

for messages tied to the current gameplay World, level, or match.

Examples:

Combat.DamageApplied
Enemy.Spawned
Door.Opened
Objective.ProgressChanged
Projectile.Impact

Get the subsystem:

UWorld* World = GetWorld();
if (!IsValid(World))
{
return;
}
USignalBusWorldSubsystem* Bus =
World->GetSubsystem<USignalBusWorldSubsystem>();
if (!IsValid(Bus))
{
return;
}

Use:

USignalBusGameInstanceSubsystem

for local session-level messages that may survive map travel.

Examples:

Profile.Loaded
Save.Completed
Login.StatusChanged
Matchmaking.SearchStarted
Travel.Started
Travel.Completed

Get the subsystem:

UGameInstance* GameInstance = GetGameInstance();
if (!IsValid(GameInstance))
{
return;
}
USignalBusGameInstanceSubsystem* Bus =
GameInstance->GetSubsystem<USignalBusGameInstanceSubsystem>();
if (!IsValid(Bus))
{
return;
}
Current map, match, or gameplay World
-> World bus
Local session or cross-level coordination
-> Game Instance bus

The two scopes are separate. A World publish does not reach a Game Instance subscriber.


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 scope
Quest.Advanced
FQuestSignal
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);

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


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.


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.


Use the callback binding style that matches your object lifetime and ownership.

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.
}
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 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 Publish
Subscriber Callback
After Publish

Example:

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 copies the payload and returns before subscriber callbacks run.

FSignalBusQueueResult QueueResult =
Bus->PublishQueued(
QuestAdvanced,
Payload,
this);

Expected order:

Before Queue
After Queue
Subscriber Callback

Use 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 seconds

Use FTimerManager or another scheduler for timed delivery.


Always inspect publish results when failure matters.

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.

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.


USTRUCT()
struct FDoorOpenedSignal
{
GENERATED_BODY()
UPROPERTY()
FName DoorId = NAME_None;
UPROPERTY()
TWeakObjectPtr<AActor> Instigator;
};
const FSignalBusMessageId DoorOpened(
TEXT("World.DoorOpened"));
FDoorOpenedSignal Payload;
Payload.DoorId = DoorId;
Payload.Instigator = OpeningActor;
Bus->PublishImmediate(
DoorOpened,
Payload,
this);
DoorSubscription =
Bus->Subscribe<FDoorOpenedSignal>(
DoorOpened,
this,
FSignalBusMessageCallback::CreateUObject(
this,
&UQuestComponent::HandleDoorOpened));

Why World scope?

The door belongs to the current gameplay World.


USTRUCT()
struct FSaveCompletedSignal
{
GENERATED_BODY()
UPROPERTY()
FName SlotName = NAME_None;
UPROPERTY()
bool bSuccess = false;
UPROPERTY()
FString ErrorMessage;
};
const FSignalBusMessageId SaveCompleted(
TEXT("Save.Completed"));
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 react

Example:

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.


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.


SignalBus uses exact reflected struct identity.

The Message ID and payload struct type must both match.

These do not match:

Publisher:
Quest.Advanced
FQuestSignal
Subscriber:
Quest.Advanced
FQuestSignalExtended

Even 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.


World and Game Instance buses are separate.

This does not deliver:

Publisher:
World bus
Quest.Advanced
FQuestSignal
Subscriber:
Game Instance bus
Quest.Advanced
FQuestSignal

The 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.


Publishing through the World bus while subscribing through the Game Instance bus produces no delivery.

Fix:

Use the same scope on both sides.

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

Constructors may run without a valid World or Game Instance.

Subscribe during an appropriate lifecycle phase:

BeginPlay
Initialize
OnWorldBeginPlay
Game Instance subsystem initialization

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.


A lambda may outlive the object it captured.

Prefer:

CreateUObject

or 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.


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

Queued delivery is deferred, not time-based.

Use:

FTimerManager

for delivery after a chosen delay.


SignalBus does not replay old messages to new subscribers.

Pattern:

Read current durable state
Then subscribe for future changes

Example:

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:

Scope
Message ID
Payload type

If a subscriber should react only to one source, include a source identifier in the payload and filter inside the callback.


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

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.


Immediate subscribers may publish another immediate message.

Example:

Publish A
-> Subscriber A runs
-> Publish B
-> Subscriber B runs
-> Publish A returns

This is supported, but deep cycles can become difficult to reason about.

Avoid accidental loops such as:

Message A subscriber publishes Message B
Message B subscriber publishes Message A

Use:

  • State guards
  • Generation counters
  • Queued delivery
  • Explicit cycle detection in higher-level logic

SignalBus does not automatically prevent logical message cycles.


Queued messages are copied when accepted.

Subscribers are matched later during delivery.

This means:

Queue message
Subscriber registers before delivery
-> subscriber may receive it

and:

Queue message
Subscriber unsubscribes before delivery
-> subscriber does not receive it

A destroyed publisher does not automatically cancel a queued message.

The Publisher identity is diagnostic context, not queue ownership.


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:

  1. Was the correct subsystem used?
  2. Was the subscription active?
  3. Did the Message ID match exactly?
  4. Did the payload type match exactly?
  5. Was the correct PIE context selected?
  6. Was the publish rejected?
  7. Was the queue full?
  8. Was the owner destroyed?
  9. Was the subscription removed?
  10. Was delivery Immediate or Queued?

The Publisher parameter improves diagnostic identity but does not affect delivery.


Prefer:

  • Small payload structs
  • Event-based publishing
  • One subscription per intended listener
  • CreateUObject for 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

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.


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:

Before
Callback
After

A queued-order assertion is:

Before
After
Callback

For every C++ SignalBus flow, confirm:

Valid World or Game Instance
Correct subsystem
Stable Message ID
Reflected payload struct
Exact payload type match
Stored subscription handle
Weak lifetime owner
Safe callback binding
Explicit cleanup where appropriate
Intentional Immediate or Queued mode
Game-thread execution
Networking handled separately

The simplest reliable pattern is:

Define a small USTRUCT
Create one stable Message ID
Subscribe once
Store the handle
Publish only when state changes
Unsubscribe during teardown