🧭 Lesson 7.4: Subsystems — The Modern Singleton
Every game needs global-ish services: a save manager, an inventory system, an audio director, a match manager. The old answer was singletons or a bloated GameInstance — both messy. Subsystems are Unreal's clean, lifetime-managed replacement, and once you use them you won't go back.
🎯 Learning Objectives
By the end of this lesson, you will be able to:
- Explain why subsystems replace manual singletons
- Pick the right subsystem type by desired lifetime
- Write a
UGameInstanceSubsystemwithInitialize/Deinitialize - Access a subsystem from C++ and Blueprint
- Use a subsystem as the home for the save manager from Lesson 7.3
Estimated Time: 60 minutes
Engine Version: Unreal Engine 5.8
In This Lesson
Why Not Singletons?
A hand-rolled singleton in a game engine causes real pain: when is it created and destroyed? Who owns it? How does it get the world? Does it leak between play sessions in the editor? You end up writing lifetime bookkeeping and static-access boilerplate — and getting it subtly wrong.
📖 What a subsystem gives you for free
- Automatic lifetime — the engine creates it when its owner (GameInstance, World, etc.) is created and destroys it with the owner. No manual
new/delete. - Clean access — a typed
GetSubsystem<T>call, no global variable. - Editor-safe — no leaking singletons between PIE sessions.
- Blueprint-accessible — subsystems are exposed to Blueprint automatically.
✅ The one-line pitch
A subsystem is "a UObject the engine automatically creates, manages, and tears down alongside a well-defined owner, with typed access built in." It's a managed singleton with the sharp edges filed off.
The Subsystem Types
You choose a subsystem type by the lifetime you want. Each is tied to a host object and lives exactly as long as it does.
lives whole game session"] --> GIu["Owner: GameInstance"] W["UWorldSubsystem
lives per level/world"] --> Wu["Owner: World"] LP["ULocalPlayerSubsystem
lives per local player"] --> LPu["Owner: LocalPlayer"] E["UEditorSubsystem
editor-only tools"] --> Eu["Owner: Editor"]
UEnhancedInputLocalPlayerSubsystem in Module 4 is a LocalPlayer subsystem.| Type | Lives as long as | Good for |
|---|---|---|
UGameInstanceSubsystem | The whole game session (across level loads) | Save manager, audio director, online session |
UWorldSubsystem | The current world/level | Spawn manager, level-specific director |
ULocalPlayerSubsystem | A local player | Input mapping (as you saw), per-player UI state |
UEditorSubsystem | The editor session | Editor tooling (Module 11-adjacent) |
💡 The deciding question: "how long should this service live?" Persist across level changes → GameInstance. Tied to one level → World. Per player → LocalPlayer. Match the lifetime and the engine handles the rest.
Writing a Subsystem
Subclass the type and override Initialize/Deinitialize — the subsystem's own lifecycle hooks (analogous to BeginPlay/EndPlay for actors).
// InventorySubsystem.h
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "InventorySubsystem.generated.h"
UCLASS()
class MYPROJECT_API UInventorySubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
// Called when the subsystem is created (GameInstance startup).
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
// Called when it's torn down (GameInstance shutdown).
virtual void Deinitialize() override;
UFUNCTION(BlueprintCallable, Category = "Inventory")
void AddItem(FName ItemId, int32 Count);
UFUNCTION(BlueprintPure, Category = "Inventory")
int32 GetItemCount(FName ItemId) const;
private:
UPROPERTY()
TMap<FName, int32> Items; // survives level loads with the GameInstance
};
// InventorySubsystem.cpp
#include "InventorySubsystem.h"
void UInventorySubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
UE_LOG(LogTemp, Log, TEXT("Inventory subsystem ready"));
}
void UInventorySubsystem::Deinitialize()
{
UE_LOG(LogTemp, Log, TEXT("Inventory subsystem shutting down"));
Super::Deinitialize();
}
void UInventorySubsystem::AddItem(FName ItemId, int32 Count)
{
Items.FindOrAdd(ItemId) += Count; // TMap from Lesson 2.4
}
int32 UInventorySubsystem::GetItemCount(FName ItemId) const
{
const int32* Found = Items.Find(ItemId);
return Found ? *Found : 0;
}
✅ No registration needed
You don't register the subsystem anywhere — the engine discovers it via reflection and instantiates it automatically for every GameInstance. Define the class, and it exists. (You can override ShouldCreateSubsystem if you want conditional creation.) The TMap lives on the GameInstance subsystem, so inventory survives traveling between levels — exactly the persistence you'd otherwise hand-roll.
Accessing It
Get a subsystem from its owner with a typed template call — no globals, no null singletons.
// From anywhere with a world context (an actor, component, etc.):
if (UGameInstance* GI = GetGameInstance()) // or GetWorld()->GetGameInstance()
{
UInventorySubsystem* Inv = GI->GetSubsystem<UInventorySubsystem>();
if (Inv)
{
Inv->AddItem(TEXT("Potion"), 3);
}
}
// World subsystem:
UMySpawnManager* SM = GetWorld()->GetSubsystem<UMySpawnManager>();
// LocalPlayer subsystem (as in Module 4):
ULocalPlayer* LP = PlayerController->GetLocalPlayer();
auto* Input = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(LP);
📖 It's the same call you already used
Back in Lesson 4.1 you wrote ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(...) without dwelling on it. Now you know: that's the subsystem access pattern, and Enhanced Input is a subsystem. The engine is built on this pattern; you're just writing your own now. Blueprint gets the same access via automatic "Get [Subsystem]" nodes.
A Save Manager Subsystem
The save/load code from Lesson 7.3 was on the GameMode — but GameMode is per-level and server-only. A save manager should persist across levels and be reachable from anywhere. That's a textbook GameInstance subsystem.
UCLASS()
class MYPROJECT_API USaveManagerSubsystem : public UGameInstanceSubsystem
{
GENERATED_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "Save")
void SaveToSlot(const FString& SlotName);
UFUNCTION(BlueprintCallable, Category = "Save")
bool LoadFromSlot(const FString& SlotName);
UFUNCTION(BlueprintPure, Category = "Save")
bool HasSave(const FString& SlotName) const
{
return UGameplayStatics::DoesSaveGameExist(SlotName, 0);
}
};
✅ Everything converges here
Now the whole module composes: the subsystem (7.4) owns save/load (7.3), which serializes state gathered via components (3.3/3.5), configured from DataAssets/DataTables (7.1), with settings in config (7.2) — all exposed to Blueprint (Module 6). A UI button anywhere calls GetGameInstance()->GetSubsystem<USaveManagerSubsystem>()->SaveToSlot("Auto"). This is what a well-architected Unreal C++ project looks like: managed services, clean data, seamless interop.
Hands-on Exercise & Quiz
🏋️ Exercise: A score subsystem
Objective: Build a global score service and wire it to an event.
- Create
UScoreSubsystem : public UGameInstanceSubsystemholding anint32 Score. - Add
BlueprintCallable AddScore(int32)and aBlueprintAssignable OnScoreChangedevent (Lesson 5.4). - Have an enemy's
OnDeath(Lesson 5.4) callAddScore— fetch the subsystem viaGetGameInstance()->GetSubsystem. - Why is GameInstance the right lifetime here rather than World?
✅ Answer
Score should persist across level transitions within a session (you don't reset the player's score just because they entered a new area). A World subsystem would be recreated per level, wiping the score. GameInstance lives the whole session, so the score survives level loads.
🎯 Quick Quiz
Question 1: A service that must persist across level loads for the whole session should be a:
Question 2: Which methods are a subsystem's lifecycle hooks?
Question 3: How do you get a GameInstance subsystem in C++?
Summary
🎉 Key Takeaways
- Subsystems are the modern singleton: engine-managed lifetime, typed access, editor-safe, Blueprint-accessible — no manual bookkeeping.
- Pick by lifetime: GameInstance (whole session), World (per level), LocalPlayer (per player), Editor (tools).
- Subclass the type, override
Initialize/Deinitialize; no registration — reflection auto-creates it. - Access with
Owner->GetSubsystem<T>()— the same call you used for Enhanced Input in Module 4. - Subsystems are the right home for global services — save managers, inventory, score — pulling the whole module together.
📚 Additional Resources
🚀 What's Next?
Module 7 is complete — your game is data-driven, configurable, persistent, and organized around clean services. Module 8 goes under the build: modules, the build system, and plugins — how to structure, extend, and package your C++ at the project level.
🎉 Module 7 complete!
Data, persistence, and services mastered. Let's look at how it all builds.