Advanced C++ Architecture: Building Scalable Systems in Unreal

If you find yourself casting to AMyCharacter every time you need to apply damage or check an inventory, you’re building a house of cards. One change to your character class, and the whole project crumbles.

Professional Unreal development is about decoupling: designing systems that cooperate without knowing each other’s concrete types. This guide connects the dots between the three pillars of scalable gameplay architecture:

  1. Contracts (Interfaces): “What can this thing do?”
  2. Signals (Delegates): “Something happened.”
  3. Routing (Event Managers): “Connect producers to consumers without spaghetti references.”

OOP on Unreal Engine C++

Unreal adds reflection, interfaces, and a full delegate/event system on top of standard C++. The goal is still the same: reduce coupling and make your gameplay code resilient to change.

Abstract - Interface

In pure C++ you might write a pure virtual like virtual void MyFunc() = 0;. In Unreal you can also use PURE_VIRTUAL when you want an abstract function declaration but still satisfy the reflection toolchain:

virtual void MyPureFunction() PURE_VIRTUAL(AMyActor::MyPureFunction, );

If the function must return a value, provide a default return expression:

virtual uint32 GetIndex() PURE_VIRTUAL(AMyActor::GetIndex, return 0;);

That “contract first” mindset is exactly what Interfaces formalize.

Interfaces: The “Contract” Pattern

Interfaces allow different classes to work together without knowing anything about each other’s internals.

Use an interface when you want to request an action from something (Host, Join, RefreshList) without casting to a specific class.

MenuInterface.h

#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "MenuInterface.generated.h"

// This class does not need to be modified.
UINTERFACE(MinimalAPI)
class UMenuInterface : public UInterface
{
	GENERATED_BODY()
};

/**
 * Interface for Menu callbacks
 */
class YOURGAME_API IMenuInterface
{
	GENERATED_BODY()

public:
	virtual void Host() = 0;
	virtual void Join(const FString& Address) = 0;
	virtual void LoadMainMenu() = 0;
    
    // Pure Virtual with Verify
    virtual void RefreshServerList() PURE_VIRTUAL(IMenuInterface::RefreshServerList, );
};

Event Handling

Interfaces are great for direct requests (“do X”). Event handling is for notifications (“X happened”).

In Unreal, Delegates (and related Event macros) are the standard tool: producers broadcast without knowing who is listening, and listeners can subscribe/unsubscribe safely.

Single Delegate

A single-cast delegate is a one-to-one callback (one listener). Use it when exactly one system should respond (e.g., “OnFinishedLoading”).

Multicast Delegate

A multicast delegate is one-to-many (multiple listeners). Use it for events like “OnBossDied” or “OnMatchStarted”.

Event

Unreal uses the word “event” in a few ways. In pure C++ you’ll see macros like DECLARE_DELEGATE, DECLARE_MULTICAST_DELEGATE, and DECLARE_EVENT. If you want Blueprints to bind, you’ll typically use dynamic delegates (e.g., DECLARE_DYNAMIC_MULTICAST_DELEGATE) and expose them as UPROPERTY(BlueprintAssignable).

Below is a minimal speaker/listener example using a C++ multicast delegate:

SpeakerActor.h

DECLARE_MULTICAST_DELEGATE_OneParam(FMessageDelegate, FString);

UCLASS()
class YOURGAME_API ASpeakerActor : public AActor
{
    GENERATED_BODY()
public:
    FMessageDelegate MessageDelegate;
    
    // Sends a message to listener
    void Speak();
};

SpeakerActor.cpp

void ASpeakerActor::Speak()
{
    // Broadcast will call back all bound listener functions
    MessageDelegate.Broadcast(TEXT("Hello from Speaker"));
}

ListenerActor.h

UCLASS()
class YOURGAME_API AListenerActor : public AActor
{
    GENERATED_BODY()
private:
    FDelegateHandle DelegateHandle;
    ASpeakerActor* Speaker;

    UFUNCTION()
    void ReceiveMessage(FString Message);
    
protected:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;
};

ListenerActor.cpp

void AListenerActor::BeginPlay()
{
    Super::BeginPlay();
    
    // Find speaker and bind
    TArray<AActor*> TaggedActors;
    UGameplayStatics::GetAllActorsWithTag(GetWorld(), "Speaker", TaggedActors);
    
    if(TaggedActors.Num() > 0){
        Speaker = Cast<ASpeakerActor>(TaggedActors[0]);
        // Bind raw C++ function or UFunction
        DelegateHandle = Speaker->MessageDelegate.AddUObject(this, &AListenerActor::ReceiveMessage);
    }  
}

void AListenerActor::ReceiveMessage(FString Message){
    UE_LOG(LogTemp, Warning, TEXT("Delegate Message: %s"), *Message);
}

void AListenerActor::EndPlay(const EEndPlayReason::Type EndPlayReason){
    if(Speaker){
        Speaker->MessageDelegate.Remove(DelegateHandle);
    }
    Super::EndPlay(EndPlayReason);
}

Event Manager

As your project grows, you can accidentally recreate coupling by directly binding listeners to specific invokers everywhere.

An Event Manager gives you a single place to connect and disconnect relationships so:

  • listeners don’t need to find speakers (and vice versa)
  • bindings can be cleaned up consistently
  • adding a new invoker doesn’t require touching every listener

DelegateDeclarations.h

#include "UObject/NoExportTypes.h"
DECLARE_MULTICAST_DELEGATE_OneParam(FMessageEvent, FString);

UCLASS()
class YOURGAME_API ADelegateDeclarations : public AActor
{
	GENERATED_BODY()
};

EventManagerActor.h

#include "YourSpeakerActor.h"
#include "YourListenerActor.h"
#include "Delegates/DelegateInstanceInterface.h"
	
UCLASS()
class YOURGAME_API AEventManagerActor : public AActor
{
	GENERATED_BODY()
	
public:
	void AddInvoker(AYourSpeakerActor* Invoker);
	void RemoveInvoker(AYourSpeakerActor* Invoker);
	void AddListener(AYourListenerActor* Listener);
	void RemoveListener(AYourListenerActor* Listener);
	
private:
	TArray<AYourSpeakerActor*> MessageEventInvokers;
	
    // Map Listener -> (Map Speaker -> Handle)
    TMap<AYourListenerActor*, TMap<AYourSpeakerActor*, FDelegateHandle>> MessageEventListeners;
};

EventManagerActor.cpp

void AEventManagerActor::AddInvoker(AYourSpeakerActor* Invoker){
    MessageEventInvokers.Add(Invoker);
    
    // When a new speaker joins, connect existing listeners to it
    for(auto &Element : MessageEventListeners)
    {
        AYourListenerActor* Listener = Element.Key;
        FDelegateHandle Handle = Invoker->GetMessageEvent().AddUObject(Listener, &AYourListenerActor::ReceiveMessage);
        
        Element.Value.Add(Invoker, Handle);
    }
}

void AEventManagerActor::AddListener(AYourListenerActor* Listener){
    // When a new listener joins, connect it to all existing speakers
    for(AYourSpeakerActor* Invoker : MessageEventInvokers)
    {
        FDelegateHandle Handle = Invoker->GetMessageEvent().AddUObject(Listener, &AYourListenerActor::ReceiveMessage);
        MessageEventListeners.FindOrAdd(Listener).Add(Invoker, Handle);
    }
}

Next Steps