Appearance
Tutorial: LiveOps Features
Integrate game configs, A/B testing, segmentation, and LiveOps events into your game.
Appearance
Integrate game configs, A/B testing, segmentation, and LiveOps events into your game.
In the previous Tutorial: Minimal SDK Integration chapter, we integrated the Metaplay SDK into our sample project, making it ready for adopting Metaplay's advanced features.
In this chapter, we will integrate some of Metaplay's LiveOps features into our project. Specifically, we will add over-the-air updatable game configs, player segmentation, A/B testing, and the LiveOps events feature for dynamic game content.
We can implement each feature as its own step:
To keep the tutorial focused, we will not dive deep into the SDK features themselves. Here are the concepts we'll be applying in this chapter, along with links for further reading if you're curious:
Game configs are the center of a lot of Metaplay features, but most importantly they enable over-the-air updating and A/B testing your configuration.
We have created a Google Sheets spreadsheet to use as the source data for our game configs. You could use any other data source instead, but Google Sheets remains popular for game designers and is a good starting point.
We kept the data as simple as possible to get started, and will be incrementally adding more content to the game config sheet in upcoming steps. For convenience, we will be creating full copies of the sheet for each step of this tutorial to make it easier to follow along.
To get started, we'll move over the configuration for how often power ups and premium currency spawns during a level. Here is an excerpt of our source spreadsheet:
| Member | Value | // Comment |
|---|---|---|
| EvaluationInterval | 1.5 | The distance in meters between each possible item location |
| MinPowerUpInterval | 100 | The minimum distance in meters since the last power up, starting at 0% chance up to 100% at MaxPowerUpInterval |
| MaxPowerUpInterval | 5000 | |
| MinPremiumInterval | 1000 | The minimum distance in meters since the last premium currency, starting at 0% chance up to 100% at MaxPremiumInterval |
| MaxPremiumInterval | 10000 |
Let's start by defining the data format of our configuration. Define a new class inheriting from GameConfigKeyValue<TSelf>:
[MetaSerializable]
public class TrackConfig : GameConfigKeyValue<TrackConfig>
{
[MetaMember(1)]
public F32 EvaluationInterval { get; private set; }
[MetaMember(2)]
public int MinPowerUpInterval { get; private set; }
[MetaMember(3)]
public int MaxPowerUpInterval { get; private set; }
[MetaMember(4)]
public int MinPremiumInterval { get; private set; }
[MetaMember(5)]
public int MaxPremiumInterval { get; private set; }
}After that, create a new file SharedGameConfig.cs in the SharedCode directory, and define a new class inheriting from SharedGameConfigBase with an entry TrackConfig:
public class SharedGameConfig : SharedGameConfigBase
{
[GameConfigEntry("TrackConfig")]
public TrackConfig TrackConfig { get; private set; }
}And, that's all! We can now use TrackConfig anywhere in our code:
TrackConfig config = MetaplayClient.PlayerModel?.GameConfig.TrackConfig;
// The distance between each powerup and currency
float increment = config.EvaluationInterval.Float;Let's do something similar for the shop. A shop is a collection of items, so let's use a GameConfigLibrary instead. Define the data format for each item in the shop by inheriting from IGameConfigData<TKey>:
[MetaSerializable]
public class ShopId : StringId<ShopId> {}
[MetaSerializable]
public class ShopItem : IGameConfigData<ShopId>
{
[MetaMember(1)]
public ShopId ConfigKey { get; private set; }
[MetaMember(2)]
public Category Category { get; private set; }
[MetaMember(3)]
public PlayerReward Reward { get; private set; }
[MetaMember(4)]
public int PremiumCost { get; private set; }
[MetaMember(5)]
public int CoinCost { get; private set; }
}MetaRewards
We're using MetaRewards to define the rewards that are given to the player when they purchase an item. MetaRewards are a Metaplay feature to streamline giving items and other rewards to players, and you can find more about them on the Implementing MetaRewards page.
As you might've noticed, the shop item contains a new ConfigKey property. We're working with a dictionary of items, which means that each item needs a unique key. This is what an excerpt of our source spreadsheet looks like:
| ConfigKey #key | Category | Reward | PremiumCost | CoinCost |
|---|---|---|---|---|
| Magnet | Consumable | Magnet | 0 | 750 |
| Life | Consumable | Life | 5 | 2000 |
| Raccoon | Character | "Rubbish Raccoon" | 20 | 50000 |
| TrashCat | Character | "Trash Cat" | 0 | 0 |
| Raccoon_Safety | Accessory | "Rubbish Raccoon"/Safety | 10 | 20000 |
Let's continue. Add a new GameConfigLibrary to the SharedGameConfig:
public class SharedGameConfig : SharedGameConfigBase
{
[GameConfigEntry("TrackConfig")]
public TrackConfig TrackConfig { get; private set; }
[GameConfigEntry("Shop")]
public GameConfigLibrary<ShopId, ShopItem> Shop { get; private set; }
}And update the shop to read from the game config instead of a static configuration:
public override void Populate()
{
m_RefreshCallback = null;
foreach (Transform t in listRoot)
{
Destroy(t.gameObject);
}
// foreach (var (key, value) in Shop.Instance.Consumables)
foreach (var (key, value) in MetaplayClient.PlayerModel.GameConfig.Shop.Where(x => x.Value.Category == Category.Consumable))
{
SpawnShopItem(value, key);
}
}Lastly, we'll create and use a BuyItem action to authoritatively add the item to the user's inventory and subtract the currency:
[ModelAction(ActionCodes.BuyItem)]
public class BuyItemAction : PlayerAction
{
public ShopId ID { get; }
[MetaDeserializationConstructor]
public BuyItemAction(ShopId id)
{
ID = id;
}
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// Verify that the item the player is trying to purchase does actually exist
if (!player.GameConfig.Shop.TryGetValue(ID, out var shopItem))
return ActionResult.InvalidItem;
int newCoins = player.PlayerData.NumCoins - shopItem.CoinCost;
int newPremium = player.PlayerData.NumPremium - shopItem.PremiumCost;
// Verify that the player can afford the item
if (newCoins < 0 || newPremium < 0)
return ActionResult.InsufficientFunds;
if (commit)
{
// Purchase success! Subtract the funds and give the reward to the user
player.PlayerData.NumCoins = newCoins;
player.PlayerData.NumPremium = newPremium;
shopItem.Reward.InvokeConsume(player, source: null);
player.EventStream.Event(new ItemReceivedEvent(shopItem.Reward.Type, shopItem.Category.ToString(), 1, shopItem.CoinCost, shopItem.PremiumCost, Context.Store));
}
return MetaActionResult.Success;
}
}private static bool ExecuteBuyItem(ShopId id)
{
BuyItemAction action = new BuyItemAction(id);
return MetaplayClient.PlayerContext.ExecuteAction(action).IsSuccess;
}That's it!
We introduced game configs to configure power up spawning, populate the shop as well as an action to purchase items in a cheat-proof way. All that is left is to create and build your game configs.
The simplest way to build and publish game configs is to use Metaplay's Game Config Build Window in Unity.
metaplay dev server and it will detect that your game config files have changed and automatically publish it.Building & Publishing via LiveOps Dashboard
You can also configure the game server to be able to build and publish game configs, so that you can do builds without touching Unity. For simplicity, we'll be using the Unity editor for this tutorial. Check out Managing Game Configs from the Dashboard for more info.
If you want to play around with the game config, you can modify the values in the game config sheet and build it again.
Custom GoogleSheetBuildSource. Fill in a name and the spreadsheet url.
Now that we have a way to configure the game, we can move on to implementing player segmentation.
Segments in Metaplay are built from a set of PlayerPropertys. At it's simplest, a PlayerProperty is a scriptable way to fetch data about your player. The SDK combines sets of player properties to compare and evaluate against the player model. Segments can then be used in a variety of SDK features, from A/B testing to push notifications, but you can also use segments in your own features.
The end goal is to have segments be configurable via game configs, so that no code changes or server builds are needed to add new segments.
Before we get started creating segments, we have to create a new section in the game configs. Add a new library to the SharedGameConfig:
public class SharedGameConfig : SharedGameConfigBase
{
[GameConfigEntry("TrackConfig")]
public TrackConfig TrackConfig { get; private set; }
[GameConfigEntry("Shop")]
public GameConfigLibrary<ShopId, ShopItem> Shop { get; private set; }
[GameConfigEntry("PlayerSegments")]
[GameConfigEntryTransform(typeof(DefaultPlayerSegmentBasicInfoSourceItem))]
public GameConfigLibrary<PlayerSegmentId, DefaultPlayerSegmentInfo> PlayerSegments { get; private set; }
}The simplest player property just returns a value and has a display name. The SDK handles all the grunt work of comparisons and further evaluation. Let's create a player property that returns the total amount of soft currency a player currently has:
[MetaSerializableDerived(4)]
public class SoftCurrencyProperty : TypedPlayerPropertyId<int>
{
public override string DisplayName => "Soft Currency";
public override int GetTypedValueForPlayer(IPlayerModelBase player) => ((PlayerModel)player).PlayerData.NumCoins;
}More complex player properties are possible as well. For example, RunsWithTheme takes a parameter to determine how many runs the player has completed with a specific theme. These work with a bit of custom parsing logic that turns RunsWithHistory/Night into a new instance of RunsWithTheme with Theme set to Night. For more info, check out Defining Player Properties.
[MetaSerializableDerived(7)]
public class RunsWithThemeProperty : TypedPlayerPropertyId<int>
{
public override string DisplayName => $"Number of completed runs with theme {Theme}";
[MetaMember(1)] public string Theme { get; set; }
public override int GetTypedValueForPlayer(IPlayerModelBase player)
{
PlayerModel playerModel = (PlayerModel)player;
return playerModel.RunHistory.Count(x => x.ThemeUsed == Theme);
}
}Add all the player properties you might need at this point. We have added a few more that were appropriate for the Trash Dash project, but these will be very game-specific. You can always add more later!
With player properties defined, we can then create segments in the new PlayerSegments game config library.

PropMin and PropMax
Numeric and time properties (such as Gold or AccountAge) are checked against ranges specified by the PropMin and PropMax columns. For less than or greater than behavior, either PropMin or PropMax can be omitted. Non-numeric properties (such as LastKnownCountry, a string) are not range-compared; instead, the PropMin column is repurposed to contain the required value. The PropMax column is not used for non-numeric Properties.
You can use the LiveOps Dashboard to see all your defined segments and what segments any given player belongs to. This makes debugging segments a breeze!
metaplay dev server.
You can also visit the Player Segments page at http://localhost:5550/segments to see an overview of all configured segments.

Now that we have over-the-air configuration and segments, we can use the experiments feature of Metaplay's game configs system to add A/B tests to the game.
This time, we'll need to add a PlayerExperiments library to the ServerGameConfig (instead of SharedGameConfig), to ensure that your experiments are not leaked to all your players.
Create a new file ServerGameConfig.cs in the SharedCode directory, and define a new class inheriting from ServerGameConfigBase with an entry PlayerExperiments:
public class ServerGameConfig : ServerGameConfigBase
{
[GameConfigEntry(PlayerExperimentsEntryName)]
public GameConfigLibrary<PlayerExperimentId, PlayerExperimentInfo> PlayerExperiments { get; private set; }
}We will configure three experiments in the PlayerExperiments game config library, and assign new values to TrackConfig when a player is enrolled in an experiment. Each experiment will have a control group and two variant groups.
In this example, we've overwritten the power up spawn rates for the PowerUpTuning experiment.

Note how there are no further code changes required in this step as experiments are configured in the existing game configs! The client or server do not need any additional code to support experiments. The Metaplay SDK will handle all the work of fetching the correct experiment configuration and keeping it in sync on both the server and client.
The 3 new experiments in the game config are:
Experiments are a powerful feature that can get complicated in large games. This is a positive problem of having a successful game with lots to test!
We have built a lot of quality-of-life tooling into the LiveOps Dashboard to help manage a large amount of parallel experiments.
Premium Currency <= 50 segment.
To take more advantage of segments and targeting, we will next implement a dynamic in-game event using Metaplay's LiveOps event system. The event will temporarily enable the night theme for players, and give them a chance to permanently unlock the night theme if they play enough times.

LiveOps events usually consist of 2 different components: configuration (LiveOpsEventContent) and the player specific state (LiveOpsEventModel). We'll set this up in a re-usable fashion, the same implementation can be used if we add new themes to the game. The event content will contain the theme we're enabling, how many times the player has to attempt the theme to unlock the theme permanently, and a title to show in the game.
Define a new class inheriting from LiveOpsEventContent:
[LiveOpsEvent(1)]
public class ThemeEvent : LiveOpsEventContent
{
[MetaMember(1)]
[MetaFormDisplayProps("Theme", DisplayHint = "Which theme to enable for the users")]
[MetaFormFieldCustomValidator(typeof(ThemeValidator))]
public Theme Theme { get; private set; } = Theme.NightTime;
[MetaMember(2)]
[MetaFormDisplayProps("Perma Unlock Threshold", DisplayHint = "After how many runs the player will permanently unlock this theme")]
public int PermaUnlockThreshold = 10;
[MetaMember(3)]
[MetaFormDisplayProps("Event In-Game Title", DisplayHint = "Event title in Game UI")]
public string EventInGameTitle;
}The configuration will be shown in the dashboard using Metaplay's Generated UI framework, we've used optional attributes MetaFormDisplayProps and MetaFormFieldCustomValidator to make the form more user-friendly.
Next, define the player specific state:
[MetaSerializableDerived(1)]
public partial class ThemeEventModel : PlayerLiveOpsEventModel<ThemeEvent, PlayerModel>
{
[MetaMember(1)] public int RunsInThemeSinceStart { get; private set; }
[MetaMember(2)] public bool RewardGiven { get; private set; }
public void IncrementRunAndReward(PlayerModel player, string themeUsed)
{
if (Content.Theme.ToString() == themeUsed)
{
RunsInThemeSinceStart++;
if (RunsInThemeSinceStart >= Content.PermaUnlockThreshold && !RewardGiven)
{
RewardGiven = true;
if (!player.PlayerData.Themes.Contains(themeUsed))
{
player.PlayerData.Themes.Add(themeUsed);
}
}
}
}
}That's all you need for the event to show up in the dashboard and be able to enroll players into the event.
Next, let's update the client to show the event.
Invoke IncrementRunAndReward() from the EndRunAction:
foreach (var model in player.LiveOpsEvents.EventModels.Values.OfType<ThemeEventModel>().Where(x=>x.Phase.IsActivePhase()))
{
model.IncrementRunAndReward(player, ThemeUsed);
}We'll use a client listener to sync the event becoming active/inactive to the UI.
Add a new method to the client listener interface:
public interface IPlayerModelClientListener
{
void ThemeEventChanged();
}Invoke the client listener when the event model's phase changes:
public partial class ThemeEventModel : PlayerLiveOpsEventModel<ThemeEvent, PlayerModel>
{
protected override void OnPhaseChanged(PlayerModel player, LiveOpsEventPhase oldPhase, LiveOpsEventPhase[] fastForwardedPhases,
LiveOpsEventPhase newPhase)
{
player.ClientListener.ThemeEventChanged();
}
}Lastly, implement the client listener and hook up the UI to enable/disable the theme when the phase changes:
public partial class MetaplayService : IPlayerModelClientListener
{
public static Action NotifyThemeEventChanged;
public void ThemeEventChanged()
{
NotifyThemeEventChanged?.Invoke();
}
}void OnEnable()
{
MetaplayService.NotifyThemeEventChanged += RefreshThemeUI;
}
void OnDisable()
{
MetaplayService.NotifyThemeEventChanged -= RefreshThemeUI;
}With the events implemented, you can now use the LiveOps Dashboard to create and manage them.
metaplay dev server.ThemeEvent, fill in a name, and optionally set a schedule and/or segment for targeting. Save the event.
We have now implemented Metaplay's game configs and used it to power the segmentation, experiments and LiveOps events features. At this point, our game is over-the-air configurable and ready for a more complex LiveOps workflows without client updates.
We could stop the tutorial here and focus on expanding the new systems we've implemented. However, we will continue with one more chapter to add a bit of polish to the game that makes it more interesting to look at.