Skip to main content

🏆 Lesson 12.1: Capstone — A Complete C++ Gameplay Feature

This is it — the moment every module has led to. You'll build a complete collectible system: collectibles in the world that a player gathers, tracked by a subsystem, shown on a live HUD, saved to disk, and fully networked. It uses every skill in the course, woven into one shippable feature. No new syntax — just mastery, assembled.

🎯 Learning Objectives

By the end of this capstone, you will be able to:

  • Architect a full gameplay feature spanning data, actors, UI, persistence, and networking
  • Build a designer-extensible C++ base class with a clean interop surface
  • Route state through a subsystem and drive UI with events
  • Make the whole feature save/load and replicate correctly
  • See how the course's patterns compose into professional Unreal C++

Estimated Time: 90 minutes

Engine Version: Unreal Engine 5.8

In This Lesson

The Blueprint of the Feature

We're building a collectible system — think coins, gems, or "collect the orbs." A player walks over a collectible; it's added to their collection; the HUD updates; the total persists across sessions; and it all works in multiplayer. Here's the architecture:

graph TD DA["UCollectibleData (DataAsset)
type, value, mesh, sound"] --> A["ACollectibleBase (C++, Abstract)
overlap → collect → destroy"] A -->|"server grants"| Sub["UCollectionSubsystem
counts per type + OnCollectionChanged"] Sub -->|"event"| HUD["Collection HUD widget"] Sub -->|"gather/apply"| Save["USaveGame slot"] A -->|"BP subclass"| BP["BP_GoldCoin, BP_Gem..."]
Figure 1: The full feature. Data drives the collectibles; a subsystem tracks the collection; the HUD listens; saves persist it; networking keeps every client in sync. Every arrow is a pattern you've already built.

📖 Plan before code — the three lenses

We plan the feature through three lenses you've internalized: the interop boundary (what designers configure vs what C++ enforces, Module 6), the data flow (events, not polling, Modules 5/9), and the network categories (authoritative / replicated / cosmetic, Module 10). Nail those three and the code writes itself.

Step 1: The Data Layer

Start with data (Modules 2, 6, 7). A UENUM for the kind, a DataAsset for each collectible's config — so designers add new collectible types with no code.

// CollectibleTypes.h
UENUM(BlueprintType)
enum class ECollectibleType : uint8   // uint8 for BlueprintType (Lesson 6.3)
{
    Coin  UMETA(DisplayName = "Coin"),
    Gem   UMETA(DisplayName = "Gem"),
    Key   UMETA(DisplayName = "Key")
};

// A rich config object referenced directly (Lesson 7.1 DataAsset).
UCLASS(BlueprintType)
class MYPROJECT_API UCollectibleData : public UDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Collectible")
    ECollectibleType Type = ECollectibleType::Coin;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Collectible")
    int32 Value = 1;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Collectible")
    FText DisplayName;

    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Collectible")
    TObjectPtr<UStaticMesh> Mesh;
};

✅ Data first, always

Leading with the data layer means the rest of the system reads from configuration rather than hardcoded values. A designer makes DA_GoldCoin (value 10) and DA_RubyGem (value 50) as assets — the C++ never changes. This is the data-driven mindset from Module 7, and it's why the feature scales to any number of collectible types.

Step 2: The Collectible Actor

The C++ base (Modules 3, 5, 6, 10) — a networked collectible with the overlap-grant-destroy mechanic, an interop surface, and a cosmetic hook for designers.

// CollectibleBase.h
UCLASS(Abstract, Blueprintable)   // designer-extensible base (Lesson 6.4)
class MYPROJECT_API ACollectibleBase : public AActor
{
    GENERATED_BODY()
public:
    ACollectibleBase();
protected:
    virtual void BeginPlay() override;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Collectible")
    TObjectPtr<USphereComponent> Collision;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Collectible")
    TObjectPtr<UStaticMeshComponent> Mesh;

    // Designers assign a DataAsset per Blueprint subclass (Step 1).
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Collectible")
    TObjectPtr<UCollectibleData> Data;

    // Replicated so late/lagging clients see it as taken (Lesson 10.2).
    UPROPERTY(ReplicatedUsing = OnRep_Collected)
    bool bCollected = false;

    UFUNCTION()
    void OnRep_Collected();

    UFUNCTION()
    void OnBeginOverlap(UPrimitiveComponent* C, AActor* Other, UPrimitiveComponent* OC,
        int32 Idx, bool bSweep, const FHitResult& Hit);

    // Cosmetic hook designers implement in Blueprint (Lesson 6.1).
    UFUNCTION(BlueprintImplementableEvent, Category = "Collectible")
    void OnCollectedFX();

    // Cosmetic broadcast to all machines (Lesson 10.3).
    UFUNCTION(NetMulticast, Unreliable)
    void MulticastCollectedFX();

    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>&) const override;
};
// CollectibleBase.cpp
#include "Net/UnrealNetwork.h"

ACollectibleBase::ACollectibleBase()
{
    PrimaryActorTick.bCanEverTick = false;   // event-driven, no Tick (Lesson 11.2)
    bReplicates = true;                       // networked (Lesson 10.2)

    Collision = CreateDefaultSubobject<USphereComponent>(TEXT("Collision"));
    RootComponent = Collision;
    Collision->SetCollisionProfileName(TEXT("OverlapAllDynamic"));

    Mesh = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("Mesh"));
    Mesh->SetupAttachment(RootComponent);
    Mesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}

void ACollectibleBase::BeginPlay()
{
    Super::BeginPlay();
    if (Data && Data->Mesh) { Mesh->SetStaticMesh(Data->Mesh); }   // data-driven visuals
    Collision->OnComponentBeginOverlap.AddDynamic(this, &ACollectibleBase::OnBeginOverlap);
}

void ACollectibleBase::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& Out) const
{
    Super::GetLifetimeReplicatedProps(Out);
    DOREPLIFETIME(ACollectibleBase, bCollected);
}

void ACollectibleBase::OnBeginOverlap(UPrimitiveComponent*, AActor* Other,
    UPrimitiveComponent*, int32, bool, const FHitResult&)
{
    // Server decides (Lesson 10.1); ignore on clients & if already taken.
    if (!HasAuthority() || bCollected || !IsValid(Other) || !Data) { return; }

    // Grant to the collector's subsystem (Step 3), if it's a player.
    if (APawn* Pawn = Cast<APawn>(Other))
    {
        if (UGameInstance* GI = GetGameInstance())
        {
            if (UCollectionSubsystem* Coll = GI->GetSubsystem<UCollectionSubsystem>())
            {
                Coll->AddCollectible(Data->Type, Data->Value);   // authoritative grant
            }
        }
    }

    bCollected = true;         // replicates (Lesson 10.2)
    MulticastCollectedFX();    // cosmetic for everyone (Lesson 10.3)
    SetLifeSpan(0.1f);         // let the multicast land, then destroy (Lesson 10.4)
}

void ACollectibleBase::OnRep_Collected()
{
    if (bCollected) { Mesh->SetVisibility(false); }   // hide instantly on clients
}

void ACollectibleBase::MulticastCollectedFX_Implementation()
{
    OnCollectedFX();   // run the designer's Blueprint VFX/SFX on every machine
}

✅ Count the modules in one actor

Components & construction (3.2), collision (5.1), Cast (2.2), authority (10.1), replication + RepNotify (10.2), multicast (10.3), the destroy-timing trick (10.4), a BlueprintImplementableEvent cosmetic hook (6.1), data-driven visuals (7.1), no-Tick performance (11.2), and the Abstract, Blueprintable base pattern (6.4). Every one is a habit now — you're composing, not recalling.

Step 3: The Collection Subsystem

The tally lives in a GameInstance subsystem (Module 7) so it persists across levels and is reachable from anywhere. It broadcasts an event (Module 5) whenever the collection changes.

// CollectionSubsystem.h
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(
    FOnCollectionChanged, ECollectibleType, Type, int32, NewTotal);   // Lesson 5.4

UCLASS()
class MYPROJECT_API UCollectionSubsystem : public UGameInstanceSubsystem
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintCallable, Category = "Collection")
    void AddCollectible(ECollectibleType Type, int32 Amount);

    UFUNCTION(BlueprintPure, Category = "Collection")
    int32 GetCount(ECollectibleType Type) const;

    // Persistence (Lesson 7.3):
    UFUNCTION(BlueprintCallable, Category = "Collection")
    void SaveTo(const FString& Slot);
    UFUNCTION(BlueprintCallable, Category = "Collection")
    void LoadFrom(const FString& Slot);

    UPROPERTY(BlueprintAssignable, Category = "Collection")
    FOnCollectionChanged OnCollectionChanged;   // the HUD subscribes to this

private:
    UPROPERTY()
    TMap<ECollectibleType, int32> Totals;   // TMap (Lesson 2.4)
};
// CollectionSubsystem.cpp
void UCollectionSubsystem::AddCollectible(ECollectibleType Type, int32 Amount)
{
    int32& Total = Totals.FindOrAdd(Type);
    Total += Amount;
    OnCollectionChanged.Broadcast(Type, Total);   // notify listeners (HUD)
}

int32 UCollectionSubsystem::GetCount(ECollectibleType Type) const
{
    const int32* Found = Totals.Find(Type);
    return Found ? *Found : 0;
}

📖 The subsystem is the hub

Everything routes through here: the collectible grants to it, the HUD listens to it, the save reads/writes it. Because it's a GameInstance subsystem, the collection survives level transitions (Lesson 7.4), and any system can reach it via GetSubsystem. A clean central service like this — not scattered globals — is what a well-architected feature looks like.

Step 4: HUD, Save & Network

Now the three finishing systems, each a few lines because the groundwork is done.

The HUD listens (Module 9)

void UCollectionHUD::NativeConstruct()
{
    Super::NativeConstruct();
    if (UGameInstance* GI = GetGameInstance())
    {
        if (UCollectionSubsystem* Coll = GI->GetSubsystem<UCollectionSubsystem>())
        {
            Coll->OnCollectionChanged.AddDynamic(this, &UCollectionHUD::HandleChanged); // 5.4/9.2
            HandleChanged(ECollectibleType::Coin, Coll->GetCount(ECollectibleType::Coin)); // prime
        }
    }
}

void UCollectionHUD::HandleChanged(ECollectibleType Type, int32 NewTotal)
{
    if (CoinText && Type == ECollectibleType::Coin)   // BindWidget (Lesson 9.1)
    {
        CoinText->SetText(FText::AsNumber(NewTotal));
    }
}

The save persists (Module 7)

// A USaveGame holds the collection as plain, serializable data (Lesson 7.3).
void UCollectionSubsystem::SaveTo(const FString& Slot)
{
    UCollectionSave* Save = Cast<UCollectionSave>(
        UGameplayStatics::CreateSaveGameObject(UCollectionSave::StaticClass()));
    Save->Totals = Totals;                       // copy the map into the save
    UGameplayStatics::SaveGameToSlot(Save, Slot, 0);
}

void UCollectionSubsystem::LoadFrom(const FString& Slot)
{
    if (!UGameplayStatics::DoesSaveGameExist(Slot, 0)) { return; }
    if (UCollectionSave* Save = Cast<UCollectionSave>(
            UGameplayStatics::LoadGameFromSlot(Slot, 0)))
    {
        Totals = Save->Totals;
        // Re-broadcast so the HUD reflects the loaded values:
        for (const TPair<ECollectibleType, int32>& P : Totals)
        {
            OnCollectionChanged.Broadcast(P.Key, P.Value);
        }
    }
}

✅ Networking came almost for free

Notice we barely wrote network code in this step — the collectible actor (Step 2) already handled authority, replication, and multicast. The grant happens on the server; in a full multiplayer build you'd house per-player collections on the PlayerState (Lesson 3.1/10.4) and replicate them, reusing the exact same RepNotify → broadcast → HUD chain. The architecture anticipated multiplayer, so scaling to it is incremental, not a rewrite.

Step 5: The Designer's Half

Here's the proof the interop boundary is right — a designer builds real content with zero C++:

  1. Create DataAssets: DA_GoldCoin (Coin, value 10), DA_Ruby (Gem, value 50).
  2. Create Blueprint subclasses of ACollectibleBase: BP_GoldCoin, BP_Ruby — assign each its DataAsset.
  3. Implement OnCollectedFX in each Blueprint: spawn a sparkle, play a chime — tuned live, no compile.
  4. Place them around the level, or spawn them from a TSubclassOf<ACollectibleBase> spawner (Lesson 3.4).
  5. Design the Collection HUD widget layout and reparent it to UCollectionHUD.

📖 This is the whole thesis of the course

The programmer owns the mechanics, invariants, networking, and persistence — the things that must be correct. The designer owns the content, feel, and placement — the things that must be iterated. Neither can break the other's work. That division, powered by C++/Blueprint interop, is what makes Unreal teams productive, and it's what you now know how to build. It's the promise from Lesson 1.1, delivered.

Every Module, One Feature

Look back at what this single feature touched — the whole course, composed:

ModuleWhat it contributed to the capstone
1–2 FoundationsClasses, reflection, types (TMap, FText), memory (TObjectPtr, GC-safe refs)
3 Gameplay frameworkThe collectible actor, components, spawning, communication
4 Input & controlThe player pawn that walks into collectibles
5 Interaction & eventsCollision overlap, the OnCollectionChanged delegate
6 InteropThe Abstract, Blueprintable base, exposed data, cosmetic events
7 Data & persistenceThe DataAsset, the subsystem hub, the SaveGame
8 Build & pluginsCould ship as a reusable "CollectibleSystem" plugin
9 UIThe event-driven collection HUD
10 NetworkingAuthority, replication, RepNotify, multicast FX
11 PerformanceNo-Tick, event-driven, cache-friendly design

✅ You didn't learn 44 tricks — you learned one way of thinking

The capstone needed no new syntax because the course was never about syntax. It was about a way of building: C++ for correct mechanics, Blueprint for content; events over polling; the loosest coupling that works; data-driven design; authoritative networking; measured performance. Those principles composed into this feature, and they'll compose into whatever you build next.

Course Conclusion

🎓 You've completed C++ for Unreal Engine

From "why C++ at all?" to a networked, data-driven, persistent gameplay feature — you've crossed the full distance. You can now:

  • Write idiomatic Unreal C++ that works with the engine's systems, not against them
  • Design clean C++/Blueprint boundaries that empower designers
  • Build data-driven, persistent, networked gameplay
  • Structure, extend, and profile a real project

Most importantly, you have the judgment to choose the right tool — and that's what separates someone who writes Unreal C++ from someone who engineers with it.

🚀 Where to Go Next

  • Build something. A small complete game teaches more than any lesson. Pick a scope you can finish.
  • Read engine source. You now have the vocabulary — step into ACharacter, UGameplayStatics, the movement component, and learn from Epic's own code.
  • Go deeper on a specialization. The Gameplay Ability System (GAS), advanced networking & prediction, editor tooling, or Niagara/rendering in C++ are natural next frontiers.
  • Package a plugin. Turn a system you love — maybe this collectible system — into a reusable plugin (Module 8) for your next project.

📚 Additional Resources

🎉 Congratulations — you did it!

Twelve modules, forty-four lessons, one complete feature. You're an Unreal C++ programmer now. Go build something remarkable.

↩ Return to Course Home