top of page

The Level Transition System

  • Mike
  • Jul 3
  • 7 min read

Updated: 10 hours ago

I knew the game needed a clean way of transitioning from level to level, and I knew I wanted something seamless. Having a simple loading screen did cross my mind, but loading screens are boring, not to mention that they can take you out of the game if they are too long. A loading screen would solve a lot of problems of course but it wouldn't feel like a world you're actively playing and participating in.


So my solution was to basically juice-it-up with the approach of level streaming. The problem with level streaming however is that it can cause hitches... and no one likes hitches. So to solve this, I split the levels into sublevels. If one level has too many assets that needs to stream in, the solution was simple, just add another sublevel and stream in that sublevel after the first sublevel.




The Technical Aspects


Making the LevelStreamSystem plugin


The entire game is written in C++ in Unreal Engine. The amount of blueprints used in the game is probably not more than 20-30 blueprint nodes across the entire project.


I knew I wanted the system to be as decoupled as possible from the rest of the game, so my idea felt pretty straight forward; Make a plugin!


#include "Modules/ModuleManager.h"

class FLevelStreamSystemModule : public IModuleInterface
{
public:
	/** IModuleInterface implementation */
	virtual void StartupModule() override;
	virtual void ShutdownModule() override;
};


The Level Stream idea in a nutshell


As this was one of my first bigger systems I implemented in the game, I knew from just playing the game in its early stages that each level would have to do a cool transition when its time to go to the next level.


When it was time to load the next level, I load another world as a streaming level inside the currently running world. This allows the old and new levels to coexist temporarily.


The transition can therefore follow this general sequence:

  • Keep the current level loaded and do some animations and what not to distract the player

  • Begin streaming the next level

  • Wait until the next level and its dependencies are ready

  • Move the player to the next level through a camera transition

  • Unload the previous level

  • The new level is ready to play as all systems from the previous level has been removed


Level Stream Controller


A level typically contains gameplay actors, collision, lighting, visual decoration, audio, effects, and many other assets. Even when the loading operation is asynchronous, some work still has to happen on the game thread.


My solution was to divide each type of level into several smaller sublevels.


So a level can basically be composed of:


  • A "Main Level" that points to what sublevels to load which usually contains

    • A puzzle-design sublevel

    • A decoration sublevel

    • A lighting sublevel


To know what sublevels each "Main Level" points to, each "Main Level" has an actor, that I call ALevelAnnouncer, that has a list of DataAssets. In one of those assets, I have simply assigned the sublevels, so when the "Main Level" is loaded and the actor with the DataAsset list has begun play, the Level Stream Controller can request that DataAsset and start loading those sublevels.


So in short, this is how I have roughly made the Level Stream Controller.


DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FLevelStreamControllerChange_Signature, ULevelStreamController*, InController);

USTRUCT(BlueprintType)
struct FLevelType
{
	GENERATED_BODY()

	UPROPERTY(EditAnywhere)
	TSoftObjectPtr<UWorld> Level;
};

UCLASS(Abstract)
class LEVELSTREAMSYSTEM_API ULevelStreamController : public UObject
{
	GENERATED_BODY()

	virtual void InitializeFrom(const FLevelType& InData) {}	
	virtual void LoadLevel(const FTransform& InLevelTransform)	{}
		
	FLevelStreamControllerChange_Signature OnLevelLoaded;
	FLevelStreamControllerChange_Signature OnLevelUnloaded;
	FLevelStreamControllerChange_Signature OnLevelAndSublevelsLoaded;
};

UCLASS()
class LEVELSTREAMSYSTEM_API ULevelStreamController_MainLevel : public ULevelStreamController
{
	GENERATED_BODY()

	//~ Begin LevelStreamController Interface
	virtual void InitializeFrom(const FLevelType& InData) override;
	virtual void LoadLevel(const FTransform& InWorldTransform) override;
	//~ End LevelStreamController Interface

	UFUNCTION()
	void OnSublevelLoaded_Response(ULevelStreamController* InController);

	UFUNCTION()
	void OnSublevelUnloaded_Response(ULevelStreamController* inController);

	UPROPERTY(Transient)
	TArray<ULevelStreamController*> SubControllers;

	UPROPERTY(Transient)
	TArray<ULevelStreamController*> WaitingToLoad;

	UPROPERTY(Transient)
	TArray<ULevelStreamController*> WaitingToUnload;

	FLevelType Data;
};

void ULevelStreamController_MainLevel::LoadLevel(const FTransform& InWorldTransform)
{
	bool WasSuccess = false;

	SetResolvedWorldTransform(InWorldTransform);
	const FTransform& StreamingTransform = GetResolvedStreamingTransform();

	ULevelStreamingDynamic* Level = ULevelStreamFunctionLibrary::LoadLevel(GetWorld(), Data.Level, StreamingTransform, WasSuccess);

	Level->OnLevelShown.AddUniqueDynamic(this, &ThisClass::OnLevelShown_Response);

	if (WasSuccess)
	{
		AddLevelStreamingDynamic(Level);
	}
}

This code snippet is the essence of it. The InitializeFrom only stores the level data as a variable.


The Level Stream Controller is initiated from a gameinstance subsystem which only job is to recieve a request to load a level, and forwards it to a LevelStreamController_MainLevel that knows what to do.


One Controller Per Streamed World


Every streamable level is managed by a ULevelStreamController.


As the LevelStreamController_MainLevel knows what assets to look for in the ALevelAnnouncer, it grabs those sublevels, and creates a LevelStreamController by each type of level and starts to load them in.


UCLASS()
class LEVELSTREAMSYSTEM_API ULevelStreamController_Sublevel : public ULevelStreamController
{
	GENERATED_BODY()

	//~ Begin LevelStreamController Interface
	virtual void InitializeFrom(const FLevelType& InData) override;
	virtual void LoadLevel(const FTransform& InWorldTransform) override;
	//~ End LevelStreamController Interface
};

void ULevelStreamController_Sublevel::LoadLevel(const FTransform& InLevelTransform)
{
	bool WasSuccess;
	ULevelStreamingDynamic* Level = ULevelStreamFunctionLibrary::LoadLevel(GetOwnerActor()->GetWorld(), Data.Level, InLevelTransform, WasSuccess);

	if (WasSuccess)
	{
		Level->OnLevelShown.AddDynamic(this, &ThisClass::OnLevelShown_Response);
		AddLevelStreamingDynamic(Level);
	}
}

And when the level has finished loading, we broadcast it, and the LevelStreamController_MainLevel checks if all sublevels have been loaded in before broadcasting that all levels are now loaded and ready to be played.


The Level Announcer


ALevelAnnouncer is a very simple actor who's job is to just have a list of DataAssets about the new level. In the list of data, we have what interactables are available, which sublevels to stream in, what the level criteria are, if we should play different ambient sound, if we need to async load any assets etc.


Each DataAsset doesn't do anything by its own, they are merely there for other systems to request when the LevelAnnouncer "announce" its existence. If a system requests a DataAsset, and it isn't there, nothing happens. This is how I transition and change the HUD from the FrontEnd level (main menu level) and the rest of the puzzle levels. There is a DataAsset in each level that tells the HUD system what UI the next level should display. If its the same HUD that's already active, we can just simply play some animations on the hud and continue without switching.


To the right, I have selected the LevelAnnouncer actor in the "Main Level" and in the details panel you can see the different DataAssets that this level will operate on.
To the right, I have selected the LevelAnnouncer actor in the "Main Level" and in the details panel you can see the different DataAssets that this level will operate on.

Placing the streamed levels in world space


Loading two worlds at the same transform would place them on top of each other and we don't want that.


Each level announcer therefore exposes a bounding box representing the physical footprint of its level, along with optional padding.


When the next level appears, its controller compares its bounds against the current level’s bounds and calculates a translation.


FVector ULevelStreamController::CalculatePlacementTranslation(
	const FBox& ReferenceBounds,
	const FBox& NewLevelBounds,
	const FVector& PlacementDirection,
	const float LevelPadding)
{
	FVector Translation = FVector::ZeroVector;

	if (PlacementDirection.Y > 0.0f)
	{
		const float DesiredMinimumY =	ReferenceBounds.Max.Y + LevelPadding;
		Translation.Y = DesiredMinimumY - NewLevelBounds.Min.Y;
	}
	else
	{
		const float DesiredMaximumY =	ReferenceBounds.Min.Y - LevelPadding;
		Translation.Y = DesiredMaximumY - NewLevelBounds.Max.Y;
	}

	Translation.Z = -NewLevelBounds.Min.Z;

	return Translation;
}

When we have the new level translation, we basically use this to stream in all the sublevels at that translation.


Level Bounds


Each "Main Level" also has an actor called ALevelBounds which tells the translation calculation how far to the left or right a level should be streamed in. It also tells the calculation at what z-location we should put the level.


The level’s Z translation also compensates for the bottom of its bounds, placing its playable floor at the expected world height.


What we also can control this way is the gameplay bounds, so when we do the transition, we get the bounding box of the gameplay bounds and calculate where to place the camera when the transition takes place.


There are two bounds, one for the Gameplay and where the camera is allowed to pan around, and the level's bounds, but right now only the level's bounds Z (the green box's bottom line) is used to determine where the levels Z 0 is.
There are two bounds, one for the Gameplay and where the camera is allowed to pan around, and the level's bounds, but right now only the level's bounds Z (the green box's bottom line) is used to determine where the levels Z 0 is.

Transitioning


The world can in essence go through three phases:

enum class EWorldPlayPhase : uint8 
{ 
	Booting, 
	Transitioning, 
	ReadyToPlay 
};

Booting only happens when the game starts, and is a way for different systems to not engage too early. This was also created to be able to play in editor.


Transitioning happens the moment we start to load in a new level and ends the moment the previous level has been unloaded.


When ReadyToPlay is switched on, that's when the level "begins".


Within the Transitioning phase however, we go through three different states:

That activates a second state machine with three transition phases:

enum class ELevelTransitionPhase : uint8
{
	None,
	PreTransition,
	Transition,
	PostTransition
};

This enables different systems to tap throughout the level transition process depending on what needs to be done.


Pre-transition

The next world is prepared.


This includes:

  • Streaming the main level

  • Streaming its sublevels

  • Resolving its position

  • Registering level-specific systems

  • Distract the player with particle effects and what not


Transition

The moment the camera travels from one level to another, but in essence:

  • Moving or blending the camera and the player

  • Changing lighting

  • Playing transition effects

  • Updating UI


Post-transition

The previous world is removed and temporary states are cleaned up.


A small delay before starting the level to "take in" the new level.


Knowing when each TransitionPhase ends


The transition system does not assume that loading or any other transition will take a fixed number of seconds.


Instead, each system that cares about a level transition gets a callback, and has the option to put it self on a "wait for me" list.

void UWorldLevelTransitioningSubsystem::BroadcastNextTransitionPhase()
{
	TransitioningObjects.Reset();

	TArray<UObject*> NewTransitioningObjects;

	OnWorldLevelTransition.Broadcast(LevelTransitionPhase, NewTransitioningObjects);

	TransitioningObjects.Append(NewTransitioningObjects);
}

For example, the level streaming subsystem registers itself during pre-transition because it is waiting for the new main level and its sublevels.

void ULevelStreamSubsystem::OnLevelTransition(const ELevelTransitionPhase InTransitionPhase,
	TArray<UObject*>& OutObjectsTransitioning)
{
	if (InTransitionPhase == ELevelTransitionPhase::PreTransition)
	{
		OutObjectsTransitioning.Add(this);
	}

	if (InTransitionPhase == ELevelTransitionPhase::PostTransition)
	{
		if (UnloadPreviousLevel())
		{
			OutObjectsTransitioning.Add(this);
		}
	}
}

When the controller reports that the new level and all of its sublevels have loaded, the streaming subsystem signals that its pre-transition work is complete.


During post-transition, it registers again while waiting for the previous world to unload.


The transition state machine only advances when the active object list is empty.


Each system is allowed to finish at its own speed, but the world does not advance to the next phase until every participating system has reported completion.


Lighting, cameras, audio, UI, or any future system can use the same mechanism without being directly coupled to the level-streaming implementation.


Conclusion


This system started because I did not want to put a loading screen between every level.


That simple presentation goal eventually required a much broader architecture involving streaming controllers, data assets, world announcements, bounds-based placement, transition phases, and asynchronous completion tracking.


A feature that looks visual or simple from the player’s perspective often requires several invisible systems working together behind it.

 
 
 

Comments


bottom of page