Appearance
Appearance
For customer support and debugging purposes, it can be helpful to see a log of recent important Events for an entity such as a player or a guild. The Metaplay SDK comes with a mechanism for defining game-specific Event types and adding Events to the Event Log in-game logic code. Events are only collected on the server-side, and the Event Log of an entity can be viewed on that entity's page in the LiveOps Dashboard.
In addition to collecting Events into Event Logs, the Metaplay SDK supports sending the same Events to analytics. See Streaming Analytics Events to External Storage for more information.
Out of the box, the Metaplay SDK collects basic game-agnostic events such as player logins and guild membership changes. By following this guide, you will learn how to implement game-specific events.
📌 Note
Out of the box, the Metaplay SDK currently supports Event Logging for players and guilds. With a small amount of work, the same mechanism can be used for any custom persistent game entity. Please contact us if this is of interest to you!
In general, Events consist of structured, typed data, rather than plain strings or JSON-like objects. This allows for more compact storage. The way this is handled on the C# side is by defining custom Event types as subclasses of the appropriate base class. For player Events, the base type is PlayerEventBase
, and for guilds, GuildEventBase
. Additionally, Event types need the [AnalyticsEvent]
attribute.
For example, imagine a simple game with gems as the main resource. We are interested in knowing how players gain and lose their gems so that we can help them in the case of a dispute. The game might have something like this in PlayerAnalytics.cs
(or any other game-specific file, as desired) for events where the player spends or gains gems:
// This is the player event type.
[AnalyticsEvent(PlayerEventCodes.GemsChanged, displayName: "Gem count changed", docString: "Emitted when gem count changes")]
public class PlayerEventGemsChanged : PlayerEventBase
{
[MetaMember(1)] public string Reason { get; private set; }
[MetaMember(2)] public int Delta { get; private set; }
PlayerEventGemsChanged() { }
public PlayerEventGemsChanged(string reason, int delta)
{
Reason = reason;
Delta = delta;
}
// This description gets shown in the Dashboard.
// See "Viewing Event Logs in the LiveOps Dashboard"
// below for more information.
public override string EventDescription => $"{Reason}: {Delta}";
}
You can control the behavior of the AnalyticsEvent
with the following arguments:
typeCode
(the first argument) is used by the serializer to uniquely identify this particular Event. It is similar to the type code for ModelAction
or MetaMessage
types.displayName
argument to the AnalyticsEvent
attribute defines the title the Event type has in the LiveOps Dashboard. This argument can be omitted, in which case the default value will be derived from the class name, in this case, "Gems Changed".docString
is an optional argument to the AnalyticsEvent
attribute that allows the developer to describe the behavior of the event. This description, if given, is then shown on the Analytics Events page of the LiveOps Dashboard. In our current example, the description is quite useless, but in the case of a new feature we might change it to "Emitted when gem count changes except for Chest Packages, which are separately tracked via ChestPackageOpened
event."includeInEventLog
specifies whether the event should be included in the emitting Entity's event log. You can set it to false to avoid spamming the event log with events that should only go into external analytics storage. Defaults to true.sendToAnalytics
specifies whether the event should be sent to external analytics storage. Defaults to true.It is advisable to keep the storage requirements of the Payload types reasonably low, because lots of them may accumulate over time.
📚 Good to know
For more examples, check out the SDK-defined Event types in PlayerCoreEvents.cs
. Also, the Idler reference project contains some game-specific Events.
In game logic implemented in actions, such PlayerAction
s, GuildServerAction
s, or GuildClientAction
s, Events can be added to the Event Log via the EventStream
member in the entity's model (PlayerModel
or GuildModel
). By calling the Event
method of the EventStream
with the Event instance as the argument, the Event gets passed to a handler that both adds the Event to the entity's Event Log and collects it for analytics. The Event handling only happens on the server-side; on the client, it has no effect by default.
For example, in our imaginary game with a gem-based economy, a player might purchase buildings through actions. We could log that action as an event:
[ModelAction(ActionCodes.PlayerPurchaseBuilding)]
public class PlayerPurchaseBuilding : PlayerAction
{
public BuildingInfo BuildingToPurchase;
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// Validation of the action would go here - can the player afford
// the purchase?
(...)
if (commit)
{
// Event logging.
player.EventStream.Event(new PlayerEventGemsChanged(
reason: $"Purchased {BuildingToPurchase.Id}",
delta: -BuildingToPurchase.GemCost
));
// Rest of the action (remove gems, add building, etc).
(...)
}
return ActionResult.Success;
}
}
The logging should happen within the same PlayerAction
that does the related change to the PlayerModel
(in this example, that change is the removal of the item, etc). Naturally, the call to EventStream.Event
can also be inside another method called from the action.
Event Logging for guilds can be done the same way as for players: by calling guildModel.EventStream.Event
from a guild action:
[ModelAction(ActionCodes.GuildPokeMember)]
public class GuildPokeMember : GuildClientAction
{
public EntityId TargetPlayerId { get; private set; }
public override MetaActionResult Execute(GuildModel guild, bool commit)
{
GuildMember invokingMember = guild.Members[InvokingPlayerId];
// Validation of the action would go here
(...)
if (commit)
{
guild.EventStream.Event(new GuildEventMemberPoked(
pokingPlayerId: InvokingPlayerId,
pokingPlayerMemberInstanceId: invokingMember.MemberInstanceId,
pokingPlayerName: invokingMember.DisplayName,
pokedPlayerId: TargetPlayerId,
pokedPlayerMemberInstanceId: targetMember.MemberInstanceId,
pokedPlayerName: targetMember.DisplayName,
pokedCountAfter: targetMember.NumTimesPoked));
// Rest of the action (increase numPoked of the target member)
(...)
}
return MetaActionResult.Success;
}
}
In addition to being loggable from ModelAction
s, Events can also be logged from the server-only PlayerActor
or GuildActor
code the same way, by calling Model.EventStream.Event
:
public override void OnSessionStart(PlayerSessionParamsBase sessionParams, bool isFirstLogin)
{
// Emit an event each time a player logs in from a new location
if (!Model.LocationHistory.Contains(sessionParams.Location))
{
Model.LocationHistory.Add(sessionParams.Location);
Model.EventStream.Event(new PlayerEventConnectedFromNewLocation(sessionParams.Location));
}
}
Setting custom labels allows annotating events with custom key-value pairs which can be used to enrich events with values that are hard or inconvenient to compute later in the analytics pipeline. Custom labels are currently only supported for server-side events.
⚠️ Avoid too many labels
The labels should be used sparingly. As the labels are added to every emitted event, liberal use of labels will will significantly increase both the data storage and data transfer requirements.
The custom labels are chosen by the Source Entity of the analytics event by overriding the GetAnalyticsLabels
method in the Entity. Each custom label key is a AnalyticsLabel
from which the game derives its own labels from. For example, adding a custom Location
label for all (server-side) Player Analytics events would be as follows:
// Adding a new custom Label `Location` into PlayerActor. For future-proofness,
// adding a second custom label `IsOldAccount` is also shown as commented-out.
// In a new file
public class GameAnalyticsLabel : AnalyticsLabel
{
public static readonly GameAnalyticsLabel Location = new GameAnalyticsLabel(1, nameof(Location));
// public static readonly GameAnalyticsLabel IsOldAccount = new GameAnalyticsLabel(2, nameof(IsOldAccount));
protected GameAnalyticsLabel(int value, string name) : base(value, name) { }
}
// In PlayerActor.cs
protected override OrderedDictionary<AnalyticsLabel, string> GetAnalyticsLabels(PlayerModel model)
{
// bool isOld = model.Stats.CreatedAt.ToDateTime() < new DateTime(2020, 10, 1, 12, 00, 00, DateTimeKind.Utc)
return new OrderedDictionary<AnalyticsLabel, string>(capacity: 1)
{
{ GameAnalyticsLabel.Location, model.LastKnownLocation?.Country.ToString() ?? "XX" },
// { GameAnalyticsLabel.IsOldAccount, isOld ? "yes" : "no" },
};
}
With these additions, all events emitted by the Entity will have the labels such as:
{
...
"labels": {
"Location": "FI",
//"IsOldAccount": "yes"
}
}
If you wish to attach some additional user data to all analytics events, the Metaplay SDK offers a class called PlayerAnalyticsContext
. This can contain important or interesting information such as the player's level, their current gold, a session identifier, etc. As with custom labels, this feature should be used sparingly to avoid bloating the analytics data with unnecessary values. The analytics context is only supported in events that are sent to the server. Any client-side-only events will not have the context included.
For example, if we wanted to track the players' current amount of gold on every analytics event, we could add that state to our custom analytics context like below:
[MetaSerializableDerived(100)]
public sealed class MyCustomPlayerAnalyticsContext : PlayerAnalyticsContext
{
[MetaMember(100)] public int WalletGoldAmount { get; private set; }
MyCustomPlayerAnalyticsContext() {}
public MyCustomPlayerAnalyticsContext(int? sessionNumber, OrderedDictionary<string, string> experiments, int walletGoldAmount)
: base(sessionNumber, experiments)
{
WalletGoldAmount = walletGoldAmount;
}
}
To then use this context, we need to override the CreateAnalyticsContext
method inside the PlayerActor
:
protected override PlayerAnalyticsContext CreateAnalyticsContext(PlayerModel model, int? sessionNumber, OrderedDictionary<string, string> experiments)
{
return new MyCustomPlayerAnalyticsContext(sessionNumber, experiments, model.WalletState.gold);
}
Now, each analytics event fired by the player will have the player's current amount of gold attached to it.
When an Event is added to an entity’s Event Log, it is first stored directly in the entity’s model (PlayerModel
or GuildModel
). To avoid excessive bloat in the model, when a number of Events have accumulated in the model, they’re removed from the model and flushed into a separate database item as a chunk (called a “segment” of events).
The database-stored Events are retained for a limited duration, configurable in the server’s runtime options (see Working with Runtime Options). In the runtime options, sections EventLogForPlayer
and EventLogForGuild
sections both contain the following parameters:
RetentionDuration
: How long Events are kept before being deleted. An Event becomes eligible for deletion from the database once it is older than this. This is a TimeSpan
; for example, a value of 14.00:00:00
specifies a 14-day retention. The default is 14 days. MaxPersistedSegmentsToRetain
.NumEntriesPerPersistedSegment
: Number of Events in each segment flushed to the database. Up to this many Events will be stored in the model before they get flushed. Since this parameter affects the size of the model, it should be kept modestly-sized. The default is 50.MinPersistedSegmentsToRetain
: Minimum number of segments to retain. The purpose of this is to allow retaining some amount of latest Events even after they are older than RetentionDuration
. The default is 20 segments, i.e. 1000 Events assuming the default value of 50 for NumEntriesPerPersistedSegment
.MaxPersistedSegmentsToRetain
: Maximum number of segments to retain. This applies even if the oldest segments are younger than RetentionDuration
. This is a safeguard for limiting database storage usage. The default is 1000, i.e. 50000 Events assuming the default value of 50 for NumEntriesPerPersistedSegment
.The Event Log of a specific entity can be viewed in that entity's page in the LiveOps Dashboard. For example, the player management page contains a "Latest Player Events" card showing the player's Event Log.
The number badge displays the total number of events available, not the total number of events ever created by the player - remember that old events will be removed when the storage limit is reached.
Metaplay allows invoking a delegate method for all client-side analytics events. This is useful for forwarding client-only events to a 3rd party analytics event storage (eg, Firebase Analytics).
For example analytics events originating from connectivity issues can only be tracked on the client. It is recommended to forward these to some analytics event storage to get a more complete picture of the game’s overall health.
Collecting events from the client can also be sufficient in the early stages of a project’s life cycle when server-side event collecting might not be worth the effort yet.
Example streaming of client-side events into Firebase Analytics:
// Implement IMetaplayClientAnalyticsDelegate to get callbacks for
// analytics events on the client.
public class ApplicationstateManager : IMetaplayClientAnalyticsDelegate
{
void Start()
{
// In Metaplay initialization:
MetaplayClient.Initialize(new MetaplayClientOptions
{
// Register to listen to client-side analytics events
AnalyticsDelegate = this
});
}
// This method gets called when an analytics event happens on the eclient.
void IMetaplayClientAnalyticsDelegate.OnAnalyticsEvent(
AnalyticsEventSpec eventSpec, AnalyticsEventBase payload, IModel model)
{
// Route player and client events to Firebase, ignore all others
if (payload is PlayerEventBase || payload is ClientEventBase)
{
Debug.Log($"Forwarding analytics event to Firebase: {PrettyPrint.Verbose(payload)}");
// Convert parameters to Firebase types and log the event.
// Note that only primitive event parameters are supported by the conversion at t he moment.
Firebase.Analytics.FirebaseAnalytics.LogEvent(
eventSpec.EventType,
AnalyticsEventFirebaseConverter.ConvertParameters(payload));
}
else
Debug.Log($"Ignoring analytics event: {PrettyPrint.Verbose(payload)}");
}
}