Appearance
Appearance
PlayerModel
state. Player Conditions are used as the criteria in determining whether a given player belongs to a Player Segment.This section will help you get segmentation up and running with one simple Segment.
Let's create a new Game Config sheet for Segments. You can put this data in a Google Sheet or a .csv file, or whatever tool you use for authoring Game Configs in your project.
SegmentId #key | DisplayName | Description | PropId[0] | PropMin[0] | PropMax[0] |
---|---|---|---|---|---|
HighGold | High gold | Player has a high amount of gold | Gold | 1000 |
This sheet defines just one simple Segment, HighGold
, consisting of players with at least 1000 gold. We'll add more Segments to it later, in the More Complex Usage section.
To represent the Segments in the C# code, define a PlayerSegments
library in SharedGameConfig
:
public class SharedGameConfig : SharedGameConfigBase
{
[GameConfigEntry("PlayerSegments")]
[GameConfigEntryTransform(typeof(DefaultPlayerSegmentBasicInfoSourceItem))]
public GameConfigLibrary<PlayerSegmentId, DefaultPlayerSegmentInfo> PlayerSegments { get; private set; }
}
At this point, if you try to build the Game Configs, you will probably see an error like "No registered ConfigParser for type PlayerPropertyId
".
To fix this, you'll need to define a subclass of PlayerPropertyId
for the Gold
Property and register a parser for it. You can replace "gold" with some other simple Property that exists in your game:
[MetaSerializableDerived(1)]
public class PlayerPropertyIdGold : TypedPlayerPropertyId<int>
{
public override int GetTypedValueForPlayer(IPlayerModelBase playerBase)
{
PlayerModel player = (PlayerModel)playerBase;
return player.NumGold;
}
public override string DisplayName => "Gold";
}
Custom parsers are registered using a class that inherits from ConfigParserProvider
. Define such a class if you don't already have one, and register the PlayerPropertyId
parser in its RegisterParsers
method:
public class ConfigParsers : ConfigParserProvider
{
public override void RegisterParsers(ConfigParser parser)
{
parser.RegisterCustomParseFunc<PlayerPropertyId>(ParsePlayerPropertyId);
}
static PlayerPropertyId ParsePlayerPropertyId(ConfigLexer lexer)
{
// We need this to parse SDK-defined PlayerPropertyId types.
if (ConfigParser.TryParseCorePlayerPropertyId(lexer, out PlayerPropertyId propertyId))
return propertyId;
// Game-specific PlayerPropertyIds.
string type = lexer.ParseIdentifier();
switch (type)
{
case "Gold": return new PlayerPropertyIdGold();
}
throw new ParseError($"Invalid PlayerPropertyId in config: {type}");
}
}
It should now be possible to successfully build the Game Configs!
Let's validate that Segmentation is successfully implemented by viewing it in the LiveOps Dashboard.
The Player Segments side-bar button will take you to a page that displays all the configured Segments.
Clicking on View segment on an individual item will show details about that specific Segment. This is handy for double-checking that the Game Configs are being processed correctly.
On a player's details page, in the Dynamic Data section, a Segments card shows all the Segments that currently contain the player. This is handy for checking that a Segment's Condition is being evaluated correctly.
The Broadcasts button in the LiveOps Dashboard side-bar menu will take you to the Manage Broadcasts page where you can view existing Broadcasts and edit and create new ones. When creating a Broadcast, you can specify an audience based on Segments.
Now that we have one simple Segment configured, let's take a bit more detailed look at what can be done with more complex Segments.
A Segment's Condition is expressed as a list of requirements for Player Properties, all of which must be fulfilled in order for the player to belong to the Segment. Additionally, a Segment can be defined to include other Segments.
Let's look at a more complex example for a Segment configuration sheet:
SegmentId #key | DisplayName | Description | PropId[0] | PropMin[0] | PropMax[0] | PropId[1] | PropMin[1] | PropMax[1] | RequireAnySegment | RequireAllSegments |
---|---|---|---|---|---|---|---|---|---|---|
HighGold | High gold | Player has a high amount of gold | Gold | 10000 | ||||||
ManySwords | Many swords | Player has many sword items | ItemCount/Sword | 20 | ||||||
ManyShields | Many shields | Player has many shield items | ItemCount/Shield | 30 | ||||||
LowGoldAndSwords | Low gold and few swords | Player has a low amount both gold and sword items | Gold | 0 | 100 | ItemCount/Sword | 0 | 2 | ||
HighGoldAndSwords | High gold and swords | Player is in both HighGold and ManySwords segments | HighGold, ManySwords | |||||||
HighGoldOrSwords | High gold or swords | Player is in HighGold or ManySwords segment (or both) | HighGold, ManySwords | |||||||
Pre2021 | Pre-2021 players | Player account was created before year 2021 (UTC) | AccountCreatedAt | 2021-01-01 00:00:00 | ||||||
LongTimePlayers | Long-time players | Player account is at least 30 days old, and has logged in within the last 7 days | AccountAge | 30d | TimeSinceLastLogin | 7d | ||||
LongTimePlayersInFinland | Long-time players in Finland | Is in "Long-time players" segment, and last known location was in Finland | LastKnownCountry | FI | LongTimePlayers |
Compared to the simpler sheet in the Quick Implementation Guide section, this sheet contains additional columns as well as rows. In this sheet, we can see examples of the different sub-conditions of a Segment:
PropId
, PropMin
, and PropMax
arrays: corresponding elements in the three parallel arrays form tuples of Id, Min, Max
, each tuple representing a requirement on a single Property identified by Id
.RequireAnySegment
).RequireAllSegments
).Numeric Properties (such as Gold) and time properties (such as AccountCreatedAt or AccountAge) are checked against ranges specified by the PropMin
and PropMax
columns. 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.
The DisplayName
and Description
strings are for displaying in the LiveOps Dashboard.
📝 Note
This specific form of Segment Condition is currently the default out-of-the-box form of Condition in Metaplay. If your game needs Conditions that don't conveniently fit in this form, it's possible to define a custom Condition type by creating a custom subclass of the PlayerCondition
class. We'd be happy to hear about your use case and consider extending the out-of-the-box Conditions.
In the Quick Implementation Guide section, we defined the PlayerPropertyIdGold
class. It is a subclass of PlayerPropertyId
that identifies the "amount of gold" Player Property.
Roughly speaking, a subclass of PlayerPropertyId
is required for each different kind of Property that is referred to in the PropId
columns in the PlayerSegments
sheet. However, the PlayerPropertyId
s can contain parameters: for example, in the example sheet, the ManySwords
and ManyShields
Segments are both using the ItemCount
kind of Property, just with different items.
The following code defines the rest of the Property identifier classes used in the sheet, besides the PlayerPropertyIdGold
that we already defined. The classes derive from the TypedPlayerPropertyId<T>
class for static typing convenience, rather than directly from PlayerPropertyId
.
// Identifies the count of a specific item owned by the player.
// The specific item is determined by the ItemType member.
[MetaSerializableDerived(2)]
public class PlayerPropertyIdItemCount : TypedPlayerPropertyId<int>
{
[MetaMember(1)] public ItemInfo ItemType { get; private set; }
PlayerPropertyIdItemCount(){ }
public PlayerPropertyIdItemCount(ItemInfo itemType) { ItemType = itemType; }
public override int GetTypedValueForPlayer(IPlayerModelBase playerBase)
{
PlayerModel player = (PlayerModel)playerBase;
if (player.ItemCounts.TryGetValue(ItemType.Id, out int itemCount))
return itemCount;
else
return 0;
}
public override string DisplayName => $"Number of {ItemType.Name}";
public override string ToString() => $"{nameof(PlayerPropertyIdItemCount)}({ItemType})";
}
// Some properties referring to basic Metaplay SDK-side members of IPlayerModelBase
[MetaSerializableDerived(1000)]
public class PlayerPropertyLastKnownCountry : TypedPlayerPropertyId<string>
{
public override string GetTypedValueForPlayer(IPlayerModelBase player) => player.LastKnownLocation?.Country.IsoCode;
public override string DisplayName => $"Last known country";
}
[MetaSerializableDerived(1001)]
public class PlayerPropertyAccountCreatedAt : TypedPlayerPropertyId<MetaTime>
{
public override MetaTime GetTypedValueForPlayer(IPlayerModelBase player) => player.Stats.CreatedAt;
public override string DisplayName => $"Account creation time";
}
[MetaSerializableDerived(1002)]
public class PlayerPropertyAccountAge : TypedPlayerPropertyId<MetaDuration>
{
public override MetaDuration GetTypedValueForPlayer(IPlayerModelBase player) => player.CurrentTime - player.Stats.CreatedAt;
public override string DisplayName => $"Account age";
}
[MetaSerializableDerived(1003)]
public class PlayerPropertyTimeSinceLastLogin : TypedPlayerPropertyId<MetaDuration>
{
public override MetaDuration GetTypedValueForPlayer(IPlayerModelBase player) => player.CurrentTime - player.Stats.LastLoginAt;
public override string DisplayName => $"Time since last login";
}
The following types of Properties are currently supported:
int
or long
).F32
and F64
).MetaTime
.MetaDuration
.bool
.string
.To parse these new kinds of Property identifiers, we augment the ConfigParsers.ParsePlayerPropertyId
method we created earlier:
static PlayerPropertyId ParsePlayerPropertyId(ConfigLexer lexer)
{
// We need this to parse SDK-defined PlayerPropertyId types.
if (ConfigParser.TryParseCorePlayerPropertyId(lexer, out PlayerPropertyId propertyId))
return propertyId;
// Game-specific PlayerPropertyIds.
string type = lexer.ParseIdentifier();
switch (type)
{
case "Gold": return new PlayerPropertyIdGold();
// A bit more complex syntax:
// 'ItemCount/ITEM_TYPE' parses to PlayerPropertyIdItemCount(ITEM_TYPE)
case "ItemCount":
{
lexer.ParseToken(ConfigLexer.TokenType.ForwardSlash);
MetaRef<ItemInfo> itemType = ConfigParser.Parse<MetaRef<ItemInfo>>(lexer);
return new PlayerPropertyIdItemCount(itemType);
}
case "LastKnownCountry": return new PlayerPropertyLastKnownCountry();
case "AccountCreatedAt": return new PlayerPropertyAccountCreatedAt();
case "AccountAge": return new PlayerPropertyAccountAge();
case "TimeSinceLastLogin": return new PlayerPropertyTimeSinceLastLogin();
}
throw new ParseError($"Invalid PlayerPropertyId in config: {type}");
}
🔧 Stay tuned
Currently, defining Player Properties requires custom PlayerPropertyId
classes and parsing as seen above. In the future, we intend to implement a dynamic Segmentation Condition system to allow referring to PlayerModel
's members without additional C# work.
In addition to using Segments in Metaplay's built-in features such as Broadcasts, you can use Segments in any custom game code where the PlayerModel
and Game Configs are available. They can be used in server code, shared game logic code, as well as client code.
For example, let's say your game has shop items that can be bought with in-game currency, and these shop items should be Targeted to configured Player Segments.
You might have the following Game Config class representing such a shop item:
[MetaSerializable]
public class ShopItemInfo : IGameConfigData<ShopItemId>
{
[MetaMember(1)] public ShopItemId Id { get; private set; }
[MetaMember(2)] public int DiamondCost { get; private set; }
[MetaMember(3)] public List<PlayerReward> Contents { get; private set; }
[MetaMember(4)] public List<MetaRef<PlayerSegmentInfoBase>> Segments { get; private set; }
public ShopItemId ConfigKey => Id;
}
Here, the Segments
member contains references to items in the PlayerSegments
config. In the shop item config sheet, Segments
would specify a list of Segment ids.
Then, an action to buy a shop item could look like this:
[ModelAction(ActionCodes.PlayerBuyShopItem)]
public class PlayerBuyShopItem : PlayerAction
{
public ShopItemInfo ShopItem { get; private set; }
PlayerBuyShopItem(){ }
public PlayerBuyShopItem(ShopItemInfo shopItem){ ShopItem = shopItem; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
bool availableToPlayer = ShopItem.Segments.Any(segment => segment.Ref.PlayerCondition.MatchesPlayer(player));
if (!availableToPlayer)
return ActionResult.NotAvailableForPlayer;
if (player.NumDiamonds < ShopItem.DiamondCost)
return ActionResult.NotEnoughResources;
if (commit)
{
player.NumDiamonds -= ShopItem.DiamondCost;
foreach (PlayerReward content in ShopItem.Contents)
content.Consume(player, source: null);
}
return MetaActionResult.Success;
}
}
Here, Targeting is implemented in the availableToPlayer
check by calling the MatchesPlayer
method of the Segments' PlayerCondition
s.
In the same manner, client-side UI code could evaluate the Segments' Conditions to determine whether the shop item should be displayed.
New Segments are created by adding new rows to the PlayerSegments
sheet. If the new Segment can be represented using existing Properties, then the updated PlayerSegments
config can be simply published to the Game Backend.
If the new Segment requires Properties that are not yet defined in the C# code, then a game update (both server and client) is needed before the new config can be published. Specifically, the game needs to be updated to recognize the new Properties: a new PlayerPropertyId
class and parser needs to be created, as described in section Defining Player Properties.
🔔 Stay tuned
As mentioned in section Defining Player Properties, we intend to implement a dynamic Condition system, which would allow referring to PlayerModel
's members without dedicated PlayerPropertyId
classes, reducing the need for custom C# code and game updates.
When using multiple simultaneous Experiments in Metaplay, you may want a way to ensure that players are not enrolled into multiple Experiments at the same time. A great way to achieve this would be to divide your player base into distinct groups where each player only exists in one single group. This is where Playerbase Subset conditions come in. These special conditions can be used to define unique, non-overlapping subsets of your player base.
Here is an example of creating ten subsets:
SegmentId #key | DisplayName | Description | PropId[0] | PropMin[0] | PropMax[0] |
---|---|---|---|---|---|
Subset1 | Subset 1 | General playerbase subset 1 of 10 | PlayerbaseSubset/10 | 1 | 1 |
Subset2 | Subset 2 | General playerbase subset 2 of 10 | PlayerbaseSubset/10 | 2 | 2 |
Subset3 | Subset 3 | General playerbase subset 3 of 10 | PlayerbaseSubset/10 | 3 | 3 |
Subset4 | Subset 4 | General playerbase subset 4 of 10 | PlayerbaseSubset/10 | 4 | 4 |
Subset5 | Subset 5 | General playerbase subset 5 of 10 | PlayerbaseSubset/10 | 5 | 5 |
Subset6 | Subset 6 | General playerbase subset 6 of 10 | PlayerbaseSubset/10 | 6 | 6 |
Subset7 | Subset 7 | General playerbase subset 7 of 10 | PlayerbaseSubset/10 | 7 | 7 |
Subset8 | Subset 8 | General playerbase subset 8 of 10 | PlayerbaseSubset/10 | 8 | 8 |
Subset9 | Subset 9 | General playerbase subset 9 of 10 | PlayerbaseSubset/10 | 9 | 9 |
Subset10 | Subset 10 | General playerbase subset 10 of 10 | PlayerbaseSubset/10 | 10 | 10 |
This would create ten segments (named Subset1
to Subset10
), each containing 10% of your player base. You can configure as many subsets as you like, but ten is a good starting point that allows you to target groups of players in 10% chunks.