Implement feature flags using Cloud Save
Overview
AccelByte Gaming Services (AGS) Cloud Save allows you to flag certain features to be enabled or disabled remotely without the need for a game update. This guide will walk you through implementing these feature flags into your game so you can turn them on and off in the AGS Admin Portal.
Prerequisites
You will need to have installed AGS Online Subsystem (OSS) version 0.12.0 and AGS Game SDK Unreal 24.7.0 or later.
You will also need to set up in-game configurations in game records.
This guide will use Byte Wars with Unreal Engine v5.1, so be sure to be sure to clone and install the necessary files if you want to follow along. The Unreal Engine Byte Wars GitHub can be found here, and the tutorial for getting Cloud Save up and running in Byte Wars Unreal is here.
Implement game record cloud save
This section will walk you through implementing game record cloud saves.
Create the game configuration flag in your project's .ini
file. In this example, EnableMatchmaking
and EnableStore
flags are created and set to true
in the DefaultEngine.ini
file.
[GameConfig]
EnableMatchmaking=true
EnableStore=true
If you're following the Byte Wars module, game records haven't been implemented yet. Follow these steps to do so.
Add the following code into CloudSaveSubsystem.h
:
public:
void GetGameRecord(const APlayerController* PlayerController, const FString& RecordKey, const FOnGetCloudSaveRecordComplete& OnGetRecordComplete);
private:
void OnGetGameRecordComplete(int32 LocalUserNum, const FOnlineError& Result, const FString& Key, const FAccelByteModelsGameRecord& GameRecord, const FOnGetCloudSaveRecordComplete OnGetRecordComplete);
FDelegateHandle OnGetGameRecordCompletedDelegateHandle;
Add this code CloudSaveSubsystem.cpp
at the UCloudSaveSubsystem::GetGameRecord
declaration:
void UCloudSaveSubsystem::GetGameRecord(const APlayerController* PlayerController, const FString& RecordKey, const FOnGetCloudSaveRecordComplete& OnGetRecordComplete)
{
if (!ensure(CloudSaveInterface.IsValid()))
{
UE_LOG_CLOUDSAVE_ESSENTIALS(Warning, TEXT("Cloud Save interface is not valid."));
return;
}
const ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
ensure(LocalPlayer != nullptr);
int32 LocalUserNum = LocalPlayer->GetControllerId();
OnGetGameRecordCompletedDelegateHandle = CloudSaveInterface->AddOnGetGameRecordCompletedDelegate_Handle(LocalUserNum, FOnGetGameRecordCompletedDelegate::CreateUObject(this, &ThisClass::OnGetGameRecordComplete, OnGetRecordComplete));
CloudSaveInterface->GetGameRecord(LocalUserNum, RecordKey);
}
Add this code into CloudSaveSubsystem.cpp
at the UCloudSaveSubsystem::OnGetGameRecordComplete
declaration:
void UCloudSaveSubsystem::OnGetGameRecordComplete(int32 LocalUserNum, const FOnlineError& Result, const FString& Key, const FAccelByteModelsGameRecord& GameRecord, const FOnGetCloudSaveRecordComplete OnGetRecordComplete)
{
FJsonObject RecordResult;
if (Result.bSucceeded)
{
RecordResult = GameRecord.Value.JsonObject.ToSharedRef().Get();
UE_LOG_CLOUDSAVE_ESSENTIALS(Log, TEXT("Success to get game record."));
}
else
{
UE_LOG_CLOUDSAVE_ESSENTIALS(Log, TEXT("Failed to get game record. Message: %s"), *Result.ErrorMessage.ToString());
}
CloudSaveInterface->ClearOnGetGameRecordCompletedDelegate_Handle(LocalUserNum, OnGetGameRecordCompletedDelegateHandle);
OnGetRecordComplete.ExecuteIfBound(Result.bSucceeded, RecordResult);
}
Create the defined strings below for our game config flag to use later when we retrieve it from Cloud Save.
#define GAME_CONFIG_KEY FString(TEXT("GameConfig"))
#define ENABLE_MATCHMAKING_KEY FString(TEXT("enableMatchmaking"))
#define ENABLE_STORE_KEY FString(TEXT("enableStore"))
Implement game configuration
Next, you will implement the game configuration. You can get the game configuration value you created before from DefaultEngine.ini
using this code:
bool bEnableMatchmaking;
GConfig->GetBool(TEXT("GameConfig"), TEXT("EnableMatchmaking"), bEnableMatchmaking, GEngineIni);
bool bEnableStore;
GConfig->GetBool(TEXT("GameConfig"), TEXT("EnableStore"), bEnableStore, GEngineIni);
You can also save the game configuration value in variable instead of always reading it from the DefaultEngine.ini
file.
Add this code to AccelByteWarsGameInstance.h
:
public:
void SetEnableMatchmaking(bool InValue);
void SetEnableStore(bool InValue);
bool GetEnableMatchmaking();
bool GetEnableStore();
private:
bool bEnableMatchmaking = false;
bool bEnableStore = false;
Then, add this code to AccelByteWarsGameInstance.cpp
:
void UAccelByteWarsGameInstance::SetEnableMatchmaking(bool InValue)
{
bEnableMatchmaking = InValue;
}
void UAccelByteWarsGameInstance::SetEnableStore(bool InValue)
{
bEnableStore = InValue;
}
bool UAccelByteWarsGameInstance::GetEnableMatchmaking()
{
return bEnableMatchmaking;
}
bool UAccelByteWarsGameInstance::GetEnableStore()
{
return bEnableStore;
}
Now, you can save the game configuration value from the DefaultEngine.ini
file to those new values we created in the game instance. If you are using Byte Wars, you can add this into LoginWidget.cpp
in ULoginWidget::NativeOnActivated()
.
bool bEnableMatchmaking;
GConfig->GetBool(TEXT("GameConfig"), TEXT("EnableMatchmaking"), bEnableMatchmaking, GEngineIni);
GameInstance->SetEnableMatchmaking(bEnableMatchmaking);
bool bEnableStore;
GConfig->GetBool(TEXT("GameConfig"), TEXT("EnableStore"), bEnableStore, GEngineIni);
GameInstance->SetEnableStore(bEnableStore);
To get the game records that we created in the AGS Admin Portal, we can call UCloudSaveSubsystem::GetGameRecord
, but make sure to call it after the player has logged in in to AGS.
In Byte Wars, we can add this into the LoginWidget
class. Add this to LoginWidget.h
.
#include "Storage/CloudSaveEssentials/CloudSaveSubsystem.h"
protected:
void OnLoadGameConfigFromCloud(const APlayerController* PlayerController, FSimpleDelegate OnComplete);
private:
UCloudSaveSubsystem* CloudSaveSubsystem;
In LoginWidget.cpp
, call the GetGameRecord
from CloudSaveSubsystem
that you created earlier:
void ULoginWidget::OnLoadGameConfigFromCloud(const APlayerController* PlayerController, FSimpleDelegate OnComplete)
{
if (!PlayerController)
{
UE_LOG_CLOUDSAVE_ESSENTIALS(Warning, TEXT("Cannot get game config from Cloud Save. Player Controller is null."));
return;
}
// Get game config from Cloud Save.
CloudSaveSubsystem = GameInstance->GetSubsystem<UCloudSaveSubsystem>();
CloudSaveSubsystem->GetGameRecord(
PlayerController,
GAME_CONFIG_KEY, //FString with value of GameConfig
FOnGetCloudSaveRecordComplete::CreateWeakLambda(this, [this, OnComplete](bool bWasSuccessful, FJsonObject& Result)
{
UE_LOG_CLOUDSAVE_ESSENTIALS(Warning, TEXT("Get game config from Cloud Save was successful: %s"), bWasSuccessful ? TEXT("True") : TEXT("False"));
// Update the local game options based on the Cloud Save record.
if (bWasSuccessful)
{
// Get game config from DefaultEngine.ini
bool bEnableMatchmaking = Result.GetBoolField(ENABLE_MATCHMAKING_KEY); // FString with value of enableMatchmaking
GConfig->SetBool(TEXT("GameConfig"), TEXT("EnableMatchmaking"), bEnableMatchmaking, GEngineIni);
GameInstance->SetEnableMatchmaking(bEnableMatchmaking);
bool bEnableStore = Result.GetBoolField(ENABLE_STORE_KEY); //FString with value of enableStore
GConfig->SetBool(TEXT("GameConfig"), TEXT("EnableStore"), bEnableStore, GEngineIni);
GameInstance->SetEnableStore(bEnableStore);
}
OnComplete.ExecuteIfBound();
})
);
}
You can retrieve your game configuration value using this code:
bool bEnableMatchmaking = Result.GetBoolField(ENABLE_MATCHMAKING_KEY);
bool bEnableStore = Result.GetBoolField(ENABLE_STORE_KEY);
Then, you can save it into DefaultEngine.ini
. This is only a runtime cache, so the real file will not be changed.
GConfig->SetBool(TEXT("GameConfig"), TEXT("EnableMatchmaking"), bEnableMatchmaking, GEngineIni);
GConfig->SetBool(TEXT("GameConfig"), TEXT("EnableStore"), bEnableStore, GEngineIni);
Save it into our game instance for later use.
GameInstance->SetEnableMatchmaking(bEnableMatchmaking);
GameInstance->SetEnableStore(bEnableStore);
Add a freeform push notification
You need to create a listener to a lobby notification that will notify about game configuration changes. First, you will create a topic for it in the AGS Admin Portal. In this example, you will create a GAMECONFIG_CHANGE
topic. In the AGS Admin Portal open your game namespace, go to Social > Push Notifications > Topics. Click + New Topic. Fill in the topic name as GAMECONFIG_CHANGE
and add the descriptions.
In the game project, you need to add a listener for the notification. First, you need to get the ApiClient
. Add this code to AuthEssentialsSubsystem.h
:
public:
AccelByte::FApiClientPtr GetApiClient();
private:
AccelByte::FApiClientPtr ApiClient;
Add this code to the AuthEssentialsSubsystem.cpp
:
AccelByte::FApiClientPtr UAuthEssentialsSubsystem::GetApiClient()
{
if (!ApiClient.IsValid())
{
UE_LOG_AUTH_ESSENTIALS(Warning, TEXT("Api Client not valid"));
return nullptr;
}
return ApiClient;
}
Set the ApiClient
at the UAuthEssentialsSubsystem::OnLoginComplete
when login is successful:
if (bLoginWasSuccessful)
{
UE_LOG_AUTH_ESSENTIALS(Log, TEXT("Login user successful."));
ApiClient = IdentityInterface->GetApiClient(LocalUserNum);
if (!ApiClient.IsValid())
{
UE_LOG_AUTH_ESSENTIALS(Warning, TEXT("Cannot get the Api Client"));
}
}
else
Then, in LoginWidget.h
, create a function delegate for the notification:
void OnMessageNotif(const FAccelByteModelsNotificationMessage& InMessage);
And the declaration for it on the LoginWidget.cpp
.
void ULoginWidget::OnMessageNotif(const FAccelByteModelsNotificationMessage& InMessage)
{
if (InMessage.Topic == "GAMECONFIG_CHANGE")
{
// do hide the corresponding button
}
}
Set the delegate function after user login is complete in the LoginWidget.cpp
at ULoginWidget::OnLoginComplete
:
const AccelByte::FApiClientPtr ApiClient = AuthSubsystem->GetApiClient();
// listen to Message Notif Lobby
const AccelByte::Api::Lobby::FMessageNotif Delegate = AccelByte::Api::Lobby::FMessageNotif::CreateUObject(this, &ULoginWidget::OnMessageNotif);
if (!ApiClient.IsValid())
{
UE_LOG_AUTH_ESSENTIALS(Warning, TEXT("Cannot get the Api Client"));
return;
}
ApiClient->Lobby.SetMessageNotifDelegate(Delegate);
For sending the push notification, you can go to the AGS Admin Portal under Social > Push Notifications > Templates and click Send Freeform.
Handle the UI button
In the Byte Wars project, there is nothing handling the UI button on the main menu. You need to add it first, so go back to AccelByteWarsGameInstance.h
and add this code:
public:
void SetPlayOnlineButton(UUserWidget* InButton);
void SetStoreButton(UUserWidget* InButton);
void SetPlayOnlineButtonVisibility(ESlateVisibility InValue);
void SetStoreButtonVisibility(ESlateVisibility InValue);
private:
UUserWidget* ButtonPlayOnline;
UUserWidget* ButtonStore;
Then, create the declaration of that function in AccelByteWarsGameInstance.cpp
.
void UAccelByteWarsGameInstance::SetPlayOnlineButton(UUserWidget* InButton)
{
ButtonPlayOnline = InButton;
}
void UAccelByteWarsGameInstance::SetStoreButton(UUserWidget* InButton)
{
ButtonStore = InButton;
}
void UAccelByteWarsGameInstance::SetPlayOnlineButtonVisibility(ESlateVisibility InValue)
{
if (!ButtonPlayOnline)
{
GAMEINSTANCE_LOG("Failed to Set Play Online Button Visibility, the button is not set");
return;
}
ButtonPlayOnline->SetVisibility(InValue);
}
void UAccelByteWarsGameInstance::SetStoreButtonVisibility(ESlateVisibility InValue)
{
if (!ButtonPlayOnline)
{
GAMEINSTANCE_LOG("Failed to Set Store Button Visibility, the button is not set");
return;
}
ButtonStore->SetVisibility(InValue);
}
After that, set the Play Online and Store buttons by adding this code into UAccelByteWarsActivatableWidget::GenerateEntryButton
at AccelByteWarsActivatableWidget.cpp
:
if (EntryWidgetClassName == "W_PlayOnline_C")
{
GameInstance->SetPlayOnlineButton(Button.Get());
}
if (EntryWidgetClassName == "W_Store_C")
{
GameInstance->SetStoreButton(Button.Get());
}
In LoginWidget.h
, create a function delegate to handle the game configuration update that will hide or unhide the corresponding button based on the config:
public:
void UpdateConfig();
private:
FSimpleDelegate OnGetGameRecordComplete;
Add the declaration for it in LoginWidget.cpp
.
void ULoginWidget::UpdateConfig()
{
if (GameInstance->GetEnableMatchmaking())
{
GameInstance->SetPlayOnlineButtonVisibility(ESlateVisibility::Visible);
}
else
{
GameInstance->SetPlayOnlineButtonVisibility(ESlateVisibility::Collapsed);
}
if (GameInstance->GetEnableStore())
{
GameInstance->SetStoreButtonVisibility(ESlateVisibility::Visible);
}
else
{
GameInstance->SetStoreButtonVisibility(ESlateVisibility::Collapsed);
}
}
Now, you can bind OnGetGameRecordComplete
with UpdateConfig
. Add this code in ULoginWidget::NativeOnActivated
:
OnGetGameRecordComplete.BindUObject(this, &ULoginWidget::UpdateConfig);
Then, you put it all together by calling OnLoadGameConfigFromCloud
function that you added before into ULoginWidget::OnLoginComplete
at LoginWidget.cpp
. Add this code for when the player is successfully logged in:
APlayerController* PlayerController = GetWorld()->GetFirstPlayerController();
ensure(PlayerController);
OnLoadGameConfigFromCloud(PlayerController, OnGetGameRecordComplete);
And add this code on ULoginWidget::OnMessageNotif
:
if (InMessage.Topic == "GAMECONFIG_CHANGE")
{
OnLoadGameConfigFromCloud(GetOwningPlayer(), OnGetGameRecordComplete);
}
Review results
Run the game and see the results. You can change the game configuration you created in the AGS Admin Portal to enable or disable the Play Online and/or Store buttons. You can give the default value for the game configuration via DefaultEngine.ini
.
[GameConfig]
EnableMatchmaking=true
EnableStore=true
Or you can change the value on the fly by using Game Record Cloud Save and Freeform Push Notification.
This is the Byte Wars main menu with Matchmaking and Store enabled:
This is the Byte Wars main menu with Matchmaking disabled from the game configuration:
This is the Byte Wars main menu with Store disabled from the game configuration.
And this is the Byte Wars main menu with Matchmaking and Store disabled from the game configuration.