[UE5] Subsystem Basics: Split Out Score Logic and Carry It to the Next Stage

Created: 2026-02-07Last updated: 2026-09-07

Sorts out GameInstance, World, and LocalPlayer by whose data they hold and how long it survives. Build a score Subsystem in C++ and try scoring, level travel, and reset from Blueprint.

You added a variable to GameInstance so score survives stage changes. Then you add volume settings, saving, and achievements, and now fixing the score means opening one large class crowded with every other feature.

Subsystem is the mechanism for placing such features separately. Here we gather the score logic into one class and call "add 100 points" and "read the current score" from Blueprint. Move to the next stage and the 300 points remain. Add another 100 and it becomes 400.

A player moving from stage 1 to stage 2 while using the same score container

What You'll Learn

  • What a Subsystem separates, and its relationship to its owner
  • How to choose between GameInstance, World, and LocalPlayer
  • Calling score logic written in C++ from Blueprint
  • What survives level travel, reset, and restarting Play

Sponsored

A Subsystem is a home for one feature

GameInstance is a home that survives level travel during a run. If you only want to hold one score for now, the GameInstance BP approach is entirely sufficient.

Subsystems help once the features you put there multiply. Split score, volume, and saving into separate classes and changing the score means opening the score class.

Splitting logic gathered in one GameInstance into score, volume, save, and achievement Subsystems

A GameInstance Subsystem runs with GameInstance as its owner. Prepare your own score class and UE creates that instance per GameInstance. You do not place an Actor in the level or add creation logic to GameInstance.

A "class" is the blueprint describing the mechanism; an "instance" is the running thing holding values. The Get node we use later does not rebuild from the blueprint each time; it fetches the same instance that GameInstance holds .

Whose data it holds, and how long it survives

Choosing a Subsystem means thinking about two things: whose data it keeps separately and how long it should remember . The former is scope; the latter is lifetime.

For a split-screen two-player game, you might share the cumulative score while keeping input settings separate for P1 and P2. Both are values you want after level travel, but the same home does not necessarily fit.

A score shared game-wide, world enemy management, and input settings split between P1 and P2
KindOwner and lifetimeWhere it fits
GameInstancePer GameInstance. Until the run endsScore across stages, save management
WorldPer World. Until that world endsManaging enemies and spawn points in a world
LocalPlayerPer local LocalPlayer. Until that player is removedPer-player input and UI settings for P1 and P2
EngineWhile the engine runsEngine-side features spanning multiple worlds
EditorWhile the editor runsAuthoring tools such as asset organization

The "Local" in LocalPlayer means a player operating on this machine . It does not mean one is created for every player connected online.

Also, Engine Subsystems are used while the game runs. Do not lump them with Editor Subsystems as "editor only". For game development, choosing from the first three above keeps things tidy.

A World and a level file are not the same unit

A World is the container where Actors live. One World can additionally load several levels or areas.

The Open Level we use here opens the destination map and swaps the World. In that case the World Subsystem is rebuilt too. With streaming that loads part of the same World, an area is added but the World Subsystem is not rebuilt.

Open Level swaps the world and its Subsystem, while additional loading into the same world keeps the same Subsystem

So for "reset the enemy count to 0 whenever you change rooms", placing it in a World Subsystem is not enough; you also need reset logic at the room switch. Keep the game's boundaries separate from the boundaries at which UE rebuilds things.

How it differs from making your own single instance

A design where "one shared instance is used" is called a singleton . Building it yourself means deciding when it is created and cleaned up.

For instance, putting the score in a static variable shared at the program level rather than per instance can leave the value alive after Stop, while the editor is open. Starting the next Play from the previous 300 points makes it hard to tell whether your reset logic is correct.

With a GameInstance Subsystem, a new Play's GameInstance gets a new score instance. You can standardize on writing initial values in Initialize() , called at start, and cleanup in Deinitialize() , called at the end.

A GameInstance Subsystem initializes at Play start, survives Open Level, and is new on the next Play after Stop

This describes GameInstance Subsystems. Engine Subsystems are not rebuilt on every Play. Variables written directly on GameInstance are also new each Play, so the main benefit of moving off the direct approach is that features separate cleanly .

Sponsored

Hands-On prep: testing score across two maps

From here we build a ScoreSubsystem for score. It assumes the CppFirstStep project used in First step into C++. Have a C++ build environment ready, plus familiarity with connecting Blueprint nodes.

Before enemies or a HUD, we change a number with a key press. Whether the logic ran is confirmed from on-screen text and the Output Log.

Pressing P three times in Level1 gives 300, moving to Level2 with N keeps 300, and another P gives 400
KeyAction
PAdd 100 points and display the current score
NOpen Level2 from Level1
RReset this run's total to 0. The run's high score remains
  1. Save a walkable third-person map as Level1 with "Save Current Level As".
  2. Duplicate it to make Level2 . Keep Player Start in both.
  3. Add a large Cube to Level2 only, so you can tell the maps apart.
  4. Reopen Level1. Set Play options to "Number of Players" 1 and "Net Mode" "Play Standalone". The usual "Selected Viewport" target is fine.

Build the operations below in each map's Level Blueprint , opened from the level editor's "Blueprints" menu → "Open Level Blueprint". To see whether values survive a level change, do not create the score itself as a variable here.

Build a score Subsystem in C++

Choose "Tools → New C++ Class", select "All Classes", and search for GameInstanceSubsystem . Use it as the parent and name the class ScoreSubsystem .

Replace the generated .h and .cpp with the code below. CPPFIRSTSTEP_API corresponds to this project's name. In another project, keep the auto-generated API macro.

The part that holds values, and the entry points callable from outside

TotalScore is this run's total and HighScore is the highest reached during the run. Both start at 0. They are private , so Blueprint cannot write them directly and changes go through the add and reset functions.

ScoreSubsystem holds TotalScore and HighScore internally and exposes AddScore, ResetRun, and two read functions
// ScoreSubsystem.h
#pragma once

#include "CoreMinimal.h"
#include "Subsystems/GameInstanceSubsystem.h"
#include "ScoreSubsystem.generated.h"

UCLASS(BlueprintType)
class CPPFIRSTSTEP_API UScoreSubsystem : public UGameInstanceSubsystem
{
    GENERATED_BODY()

public:
    virtual void Initialize(FSubsystemCollectionBase& Collection) override;
    virtual void Deinitialize() override;

    UFUNCTION(BlueprintCallable, Category = "Score")
    void AddScore(int32 Amount);

    UFUNCTION(BlueprintCallable, Category = "Score")
    void ResetRun();

    UFUNCTION(BlueprintPure, Category = "Score")
    int32 GetTotalScore() const { return TotalScore; }

    UFUNCTION(BlueprintPure, Category = "Score")
    int32 GetHighScore() const { return HighScore; }

private:
    int32 TotalScore = 0;
    int32 HighScore = 0;
};

BlueprintCallable is work callable from BP; BlueprintPure is an entry point that reads a value. Pure nodes have no white exec pin and are called when the value is needed downstream.

Initialization, scoring, and reset

// ScoreSubsystem.cpp
#include "ScoreSubsystem.h"

void UScoreSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
    Super::Initialize(Collection);
    TotalScore = 0;
    HighScore = 0;
    UE_LOG(LogTemp, Log, TEXT("ScoreSubsystem initialized. Total=0 High=0"));
}

void UScoreSubsystem::Deinitialize()
{
    UE_LOG(LogTemp, Log, TEXT("ScoreSubsystem deinitialized."));
    Super::Deinitialize();
}

void UScoreSubsystem::AddScore(int32 Amount)
{
    if (Amount <= 0)
    {
        return;
    }

    TotalScore += Amount;
    HighScore = FMath::Max(HighScore, TotalScore);
    UE_LOG(LogTemp, Log, TEXT("Score +%d Total=%d High=%d"),
        Amount, TotalScore, HighScore);
}

void UScoreSubsystem::ResetRun()
{
    TotalScore = 0;
    UE_LOG(LogTemp, Log, TEXT("Score reset. Total=%d High=%d"),
        TotalScore, HighScore);
}

Amount is the points to add, and values of 0 or less add nothing. FMath::Max picks the larger of two values, so HighScore only rises when the total exceeds the previous high. ResetRun() zeroes only TotalScore, leaving the high score.

Initialize is the initialization UE calls when the Subsystem starts being used. It is a separate call from the constructor, and here it zeroes both scores. Super:: calls the parent class's version. Deinitialize logs that it ended.

Build so the class becomes usable

Save your code and assets and close UE. In Visual Studio, select "Development Editor" and "Win64" and build the CppFirstStep game project. On success, reopen the .uproject .

The class we defined in C++ is created automatically, so do not create it with Spawn Actor or Construct Object . You also do not change the Game Instance Class in "Maps & Modes" to ScoreSubsystem.

Display the current value in Blueprint

Open Level1's Level Blueprint and right-click to search for Get Score Subsystem . That node's blue output is a reference to the running ScoreSubsystem. You use it to say "ask this instance to do something".

Drag from the blue output and create Get Total Score , and that reference connects to Target. Target is "whose total to read". Even with several Get nodes, they read the same score inside the same GameInstance.

Build one display routine

Right-click, add a Custom Event, and name it ShowTotal . Add no arguments. This event gathers the logic that displays the current total.

Reading Get Total Score from Get Score Subsystem and converting the integer to a string
  1. Connect Get Score Subsystem's blue output to Get Total Score's Target.
  2. Create To String (Integer) from Get Total Score's integer output. It converts the number into a string Print String can display.
  3. Add a Print String and connect the converted string to "In String".
  4. Connect ShowTotal's white exec wire to Print String and set Duration to 5.0 .
ShowTotal runs Print String, displaying the total it read for five seconds

The A in the diagram marks the string continuing from the previous diagram. No node needs adding for it. Enable Print String's "Print to Screen" and "Print to Log".

Next connect a node that calls Show Total from Event BeginPlay . Right-click and search for Show Total, or drag the event from My Blueprint into the graph to call it. That is not creating a second Custom Event definition.

Calling Show Total from Event BeginPlay so a newly opened level also displays the current total

Compile and Play and 0 appears on screen. Displaying 0 here means you fetched the Subsystem and called a C++ function from BP.

Wire up scoring, travel, and reset

Press P to add 100 points

Add a P key event in the Level Blueprint. The white exec order is Pressed → Add Score → Show Total .

Connect Get Score Subsystem's reference to Add Score's Target and set Amount to 100 . Adding before displaying means you see the score right after the press.

P's Pressed calls Add Score for 100 points, then Show Total displays it

Click the play window, press P three times, and confirm 100 → 200 → 300. Leave Released unconnected. One key press corresponds to one addition here.

Press R to reset this run's total to 0

Wire the R key's Pressed → Reset Run → Show Total . Reset Run's Target is also Get Score Subsystem. Reset Run takes no arguments.

R's Pressed calls Reset Run, and Show Total displays the total back at 0

The total returns to 0 but HighScore remains. Confirm with Score reset. Total=0 High=300 in the Output Log.

Press N to travel to Level2

In Level1's Level Blueprint, build the N key's Pressed → Open Level (by Name) with Level Name set to Level2 .

Calling Open Level by Name from N's Pressed to open Level2

Compile, save, then open Level2. Build the following in its Level Blueprint too.

  • The ShowTotal definition and the logic reading and displaying the current value
  • Event BeginPlay → Show Total
  • P → Add Score → Show Total
  • R → Reset Run → Show Total

Copying the same node group is fine. Do not build N travel in Level2. The key point is not to place Reset Run in either BeginPlay . Resetting on every level open would zero the value even though the Subsystem survived.

Run it and confirm the carry-over

Save both maps, return to Level1, and Play. Even if you tried R earlier, this fresh Play starts at 0.

ActionWhat you see
Play in Level10. The initialization log appears once
Press P three times100 → 200 → 300
Press NYou travel to Level2 with its large Cube, showing 300
Press P once in Level2400
Press R0. The log reads Total=0 High=400
Press P once100. The log reads Total=100 High=400
Stop and Play againBoth total and high score are 0. A new initialization log

If ScoreSubsystem initialized. does not increase across the Open Level and 300 shows at the destination, the instance holding the values survived and only the displaying level is new .

Building 300 in Level1 and moving to Level2 keeps 300, and the initialization log appears only once

The on-screen numbers vanish after five seconds. The display disappearing is not the Subsystem's value disappearing. Use the Output Log to re-read it.

ProblemWhere to check
Get Score Subsystem is not foundA successful C++ build, the BlueprintType specifier, whether the parent is GameInstanceSubsystem
Pressing P does not increase itPlay window focus, the Pressed wire, Add Score's Target, Amount = 100
The display shows the previous scoreWhether Show Total is called after Add Score
No number appears in Level2Whether you built ShowTotal and the BeginPlay call in Level2 too
It reads 0 in Level2Whether BeginPlay calls Reset Run, or whether you display a different variable
Level2 does not openThe map's save, the spelling of Level Name, errors in the Output Log
Sponsored

Bonus: when building it into a game

Change the scoring trigger to a game occurrence

Instead of the P key, call Add Score where an enemy's defeat is confirmed and it works as kill score. So the same enemy's death logic does not repeat, combine it with the already-dead check covered in HP and dying responsibilities.

Destroy Actor can be called for purposes other than defeating an enemy. Rather than "destroyed means score", scoring from where the in-game success is decided prevents points from cleaning up unneeded enemies.

To extend to a real HUD, change notifications like Event Dispatcher are an option. Even then, read the current value right after building the display . Waiting only for the next scoring notification leaves the destination screen without the earlier 300 points.

Carrying over, saving, and networking are separate

HighScore is also a value living only in the running game. To keep it for the next launch, save it with Save Game.

Also, a GameInstance Subsystem's values are not automatically shared with peers over the network. Design a shared multiplayer score together with the GameState role and networking.

Level Actors may not exist at initialization

A GameInstance Subsystem's Initialize runs early in the game's setup. Do value initialization there and avoid mixing in logic that searches for players or enemies that do not exist yet.

For worlds there is UWorldSubsystem , and UTickableWorldSubsystem when you also need per-frame work. But moving data that should survive level travel to the World side just because you want Tick changes its lifetime. Decide the owner first, then think about the update method.

When Subsystems of the same kind have initialization dependencies, Collection.InitializeDependency<T>() inside Initialize specifies what to prepare first. It is not a way to force-create Subsystems with different owners.

To limit a World Subsystem to game worlds, narrow the targets with DoesSupportWorldType() and similar. The editor has Worlds too, so "a world exists" does not mean "the game started".

Start by picking one feature out of whatever GameInstance has grown into. Once "which owner it belongs to" and "what it can be asked to do from outside" are decided, you can split things the same way as the score example.

Summary

  • A Subsystem is "a container per feature, living as long as its owner"
  • GameInstance lasts the run, World lasts the level, LocalPlayer lasts that player
  • Logic written in C++ can be called from Blueprint once you mark it

The question when choosing is "from when to when does this feature need to be alive?" That decides the owner.

To try one container first, see GameInstance basics; for how to write the C++, see First step into C++.

Reference: Programming Subsystems, The five Subsystem kinds, World Subsystem, LocalPlayer Subsystem.

Unreal Engine Notes in this section98