The importance of UI and its Architecture
Game UI Systems are:- Large and complex
There are exceptions, of course, but many, if not most, games have relatively deep progression systems, quest systems, stat systems, retention systems, and so forth. The “UI team,” in my experience, not only owns the UI presented to the user, but the underlying systems to track all the data often saved in account systems and cloud-based data stores as well as the hooks in gameplay systems to gather the data.
- Volatile
These systems tend to grow and change quite a bit during development as the team tries to find what is right for their game.
- Nuanced
The styling, look and feel, layout, and presentation are tweaked and changed a lot while trying to deal with feedback that likely includes varied opinions and results from users with different skill levels and gaming backgrounds.
Given that information, I strongly suggest spending the extra time up front to establish a strong architecture that provides:
- Separation of business logic and the visuals of your UI
- Allows quick iteration of layout and visuals
- Effective business logic debugging
- Performance
I’ll present a very effective pattern I've found at achieving these goals with a real-world example below.
The example
Let’s say you’re working on a game. Let’s pick one at random… Fortnite will do. ;)We want an Item Shop that looks something like this:
Were going to dive deep with this example to address:
- Class Architecture
- C++ code
- Blueprints
For the most part, I jump into the hows pretty quickly and cover the whys throughout the explanations and comments of the code I present.
Class Architecture
The architecture I generally suggest roughly follows this pattern:UMyData is a C++ class that inherits from UObject.
The idea is to create data classes to encapsulate all the information about a thing your UI wants to communicate to the user.It being a UObject allows us to control access with public/private, have getters/setters and include useful API.
This approach separates the lifetime of the data from the UI, which is very desirable, as store offers, inventory items, player stats, and all the other data sources rarely share the lifetime of UI objects. Furthermore, you can have multiple widgets sourcing data from the same data objects. These classes may well exist already, created by other engineers in other disciplines as part of creating the gameplay or the store backend, however, even in these cases, often times the UI needs its own data class to wrap up the gameplay data alongside more data that is UI specific.
UMyWidget is a C++ class that inherits from UUserWidget.
These C++ classes are intended to define widget specific API for use in Blueprints as well as Blueprintable events to define the contract that Blueprints must follow to properly interact with the underlying system.MyBlueprint is a Widget Blueprint that derives from UMyWidget.
In the Widget Blueprint, you create and layout all the visible UI you need, style it, and then utilizing both the API provided by UMyData and UMyWidget, populate all the UI primitives (text boxes, images, …) with the necessary data. You’ll also listen to the events UMyWidget provides to know when to update the corresponding UI. In interactive cases, you may respond to a button click and call into some provided API on the UMyData or UMyWidget.Let’s Code
Our Data Class
First, let’s put together our data for an offer in our shop.UOfferInfo derives from UObject, because we want to expose it to Blueprints and we want to have UFUNCTIONS to access our data. We’ll keep it relatively simple for the example but imagine price, image, and much more.
UENUM(BlueprintType)
enum class EOfferType : uint8
{
Normal,
Featured
};
UCLASS(BlueprintType)
class UOfferInfo : public UObject
{
GENERATED_UCLASS_BODY()
public:
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetName() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
FText GetDescription() const;
UFUNCTION(BlueprintCallable, Category = "OfferInfo")
EOfferType GetOfferType() const;
private:
// All your data including UProperties!
};
Our Shop Widget
Next, let’s look at our shop widget, which will be responsible for providing interface and behavior for the screen that shows all the offers. Showing the details about an individual offer will be relegated to a UOfferWidgetBase UserWidget we’ll look at later.// Abstract because we want to inherit a Blueprint class from this base
// but don't want users to be able to instance the base class directly
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferShopWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// This function tells the users that we have started reading offers
// The Blueprint will most likely put up a throbber and text explaining were downloading data
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnStartReadingOffers();
// We tell the Blueprint we want to generate some UI for a specific Offer
// Most likely the Blueprint will create an Offer widget and hand it the UOfferInfo* for it to do it's thing
// I suggest API like this to allow the Blueprint full control over the layout
// Maybe the visual design calls for a vertical box, and next week changes to scroll horizontally,
// or maybe a tile grid, or some combination.... you get the idea ;)
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void GenerateOffer(UOfferInfo* OfferData);
// This function tells the users that we have finished generating offers
// The Blueprint will most likely hide the throbber and complete any setup of the screen
UFUNCTION(BlueprintImplementableEvent, Category = "OfferShopWidget")
void OnOfferGenerationCompleted();
// Utility functions for the Blueprint to get data it will use
UFUNCTION(BlueprintCallable, Category = "OfferShopWidget")
FDateTime GetStoreRefreshDate() const;
protected:
// Keep all your internal function at least protected
private:
// More internal functions
// All your data including UProperties!
UPROPERTY(transient)
TArray<UOfferInfo*>CurrentOffers;
};
Our Offer Widget
Lastly, let’s look at our base C++ class for widgets to display an individual offer.To achieve the mock up, we’ll derive two Blueprints with different layouts for the two different tiles.
// Abstract because we want to inherit a Blueprint class from this base
// but don't want users to be able to instance the base class directly
UCLASS(Abstract, Blueprintable, BlueprintType, ClassGroup = UI)
class UOfferWidgetBase : public UUserWidget
{
GENERATED_UCLASS_BODY()
public:
// This is the function you have to call after Creating a new instance of one of these widgets// With this pattern you can call SetupOffer again if you want to show something different
// without creating a whole new widget, generally important for performance especially when working with
// inventories and such where you'll most likely want to pool/reuse widgets to keep the UI fast
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
void SetupOffer(UOfferInfo* InOfferData)
{
// Threw in a little implementation here to explain why I don't tend to use BluprintNativeEvents
// The primary reason is that it introduces possible user error where the call the parent function
// in the wrong place or not at all.
// The UX surrounding BlueprintImplementableEvent is also a little simpler.
OfferData = InOfferData;
OnOfferSet();
}
// We tell the Blueprint we got data to show
// The Blueprint will most likely call GetOfferInfo() then the GetName, GetDescription,
// and many more to populate it's text fields, textures and everything else
UFUNCTION(BlueprintImplementableEvent, Category = "OfferWidget")
void OnOfferSet();
// Utility functions for the Blueprint to get data it will use
UFUNCTION(BlueprintCallable, Category = "OfferWidget")
UOfferInfo* GetOfferInfo() const;
private:
UPROPERTY(transient)UOfferInfo* OfferData;
};
Let’s Blueprint
Time to create a UMG Widget that derives from our UOfferShopWidgetBase and build out a layout to accommodate the data we will populate at runtime.- FeaturedOffers_HorBox
- Takes up the left half of the screen and will be where we put our featured offers.
- NormalOffers_WrapBox
- Takes up the right half of the screen and will be where we put our normal offers.
- RefreshTime_TextBlock
- A text block to put our store refresh timer in.
And here’s the graph:
Because we let the Blueprint deal with creating the widgets and adding them into the appropriate containers, we gain the ability to:
- Change the layout easily
- Swap out the widgets used in different situations
- Modify the properties of the slot, which is returned from the “Add Child to…” calls
Now let’s create our Offer widgets. We’ll create two UMG Widgets deriving from the UOfferWidgetBase class we made in C++. Let’s call them OfferTileSmallWidget and OfferTileLargeWidget. The GenerateOffer event in the Offer Shop widget Blueprint pictured above creates these two widgets based on the Offer Type stores in our Offer Data.
After we setup the Small and Large widgets to look like our tiles from the “mockup” image at the top, we can create the graph, which in the simplest form would look something like this:
We created OfferTileSmallWidget and OfferTileLargeWidget to accommodate the shop screen, but our architecture makes going further easy. Imagine now that in some other screen in our game, we wanted to show off a hot offer!
This would take two steps really:
1. In C++, we make a function for the hot offer, and maybe it looks like this:
UOfferInfo* MyLibrary::GetTheHotOffer()
UOfferInfo* MyLibrary::GetTheHotOffer()
This just returns an instance of our OfferInfo data class.
2. Put one of our existing two widgets on the screen and call SetupOffer() with the offer GetTheHotOffer() returns.
Or maybe we create a new Blueprint “HotOfferWidget” that has a fire effect and put that in and call SetupOffer().
Or maybe we create a new Blueprint “HotOfferWidget” that has a fire effect and put that in and call SetupOffer().
Conclusion
This approach keep business logic in C++, making it easier to maintain, debug and modify. It exposes a strong event-based API for Blueprints to allow creatives the flexibility to make the UX and visual experiences they want.Some things to avoid are Blueprint Ticks, Property Binding, and, to a lesser extent, excessive animations. This approach fundamentally is about providing a set of events that expose the important edges of the system and its state. This naturally tends to discourage Blueprint Ticks and Property Binding simply because their utility is lowered given the existence of your events.
Animations are relatively expensive, certainly useful, but you should be judicious in their use, especially when performance really matters, like in a HUD where the UI budget always seems to be near zero. Anyone who’s worked on UI knows what I mean!