Appearance
Appearance
PlayerModel
s, PlayerAction
s and Tick
s. Take a look at the Getting Started section if you need a refresher.Server and Client Listeners are used by models to communicate things from the shared game logic code into the outside world: on the client, you can use them to update the visuals, and on the server, to trigger server-only behavior. The Listeners are game-specific, and you can implement them with C# interfaces or events.
A Callback is a call to a Listener, meant to trigger a method and consequently some server-side or client-only behavior.
Server and Client Listeners can signal the client or server about changes in game logic (actions or ticks) and have them trigger external events. In practice, they are a way to get side effects from a deterministic computation.
Let’s say a player acquired some items, and you’d like to notify them. Using Listeners, you can avoid checking the player’s inventory every frame for the changes you’re looking for and have it invoke a Callback instead when you acquire new items. Callbacks can pass any arguments you'd like, so you can include the item info as an argument to the Callback method to make it easier for the game UI to show the correct UI effects for this particular item.
The reason for using Listeners and Callbacks is that actions only operate within models. By default, the game client does not necessarily know to react to a model changing as a result of an action. You could use Listeners to notify the UI and other components outside the player model, like particle systems or server-side events.
Alternatively, you can also choose to track the player model’s state manually, which is safer and therefore ideal for critical operations. For these situations, you should combine Listeners with manual state tracking to guarantee that the important operation in question will be executed even in edge cases like device crashes. For example, Metaplay's in-app-purchase validation uses Listeners to start the validation operation immediately, and periodic polling of the player state ensures the purchases get validated eventually if the Listener gets interrupted or fails.
The Hello World sample project comes ready with a basic Listeners implementation using interfaces in the PlayerModel
. Here's how it all works:
First, we define the interfaces and dummy "empty" Listeners:
// The interfaces don't have any methods right now.
// We'll add some later in this document.
public interface IPlayerModelClientListener
{
}
public interface IPlayerModelServerListener
{
}
public class EmptyPlayerModelClientListener : IPlayerModelClientListener
{
public static readonly EmptyPlayerModelClientListener Instance = new EmptyPlayerModelClientListener();
}
public class EmptyPlayerModelServerListener : IPlayerModelServerListener
{
public static readonly EmptyPlayerModelServerListener Instance = new EmptyPlayerModelServerListener();
}
EmptyPlayerModelClientListener
and EmptyPlayerModelServerListener
exist because the Listeners are declared on the code shared between the client and the server. Since there are client- and server-only events, we implement empty Listeners so it's not necessary to deal with null instances of one while dealing with the other.
As such, the PlayerModel
's reference to the Listeners is initialized as empty:
public class PlayerModel : PlayerModelBase<...>
{
...
// Although these are being initialized as empty instances,
// the intention is that IPlayerModelServerListener will be
// assigned to an actor in the server execution while the Client
// Listener remains empty. Likewise, IPlayerModelClientListener
// will be assigned to a client class and the Server Listener
// will remain empty in client code.
[IgnoreDataMember]
public IPlayerModelServerListener ServerListener { get; set; }
= EmptyPlayerModelServerListener.Instance;
[IgnoreDataMember]
public IPlayerModelClientListener ClientListener { get; set; }
= EmptyPlayerModelClientListener.Instance;
...
}
Next, we assign the Listeners’ implementation to the player model at the start of a session. Assign whatever object you want to receive the Callback that have an implementation of the Listener interface.
In this case, it's the ApplicationStateManager
on the client. On the server, it's the player Actor itself.
// (or whichever class in your code implements IMetaplayLifecycleDelegate
// or otherwise handles the session lifecycle)
public class ApplicationStateManager :
...
IMetaplayLifecycleDelegate,
// Note: you don't have to implement IPlayerModelClientListener in the same
// class that implements IMetaplayLifecycleDelegate. Here, they're both
// implemented in ApplicationStateManager for simplicity.
IPlayerModelClientListener
{
...
void IMetaplayLifecycleDelegate.OnSessionStarted()
{
// Hook ApplicationStateManager up as the Listener on the client.
MetaplayClient.PlayerModel.ClientListener = this;
}
}
// PlayerActor.cs
public class PlayerActor :
PlayerActorBase<...>,
IPlayerModelServerListener
{
...
protected override void OnSwitchedToModel(PlayerModel model)
{
// OnSwitchedToModel is called when the actor is about to
// start using the given PlayerModel as its active model.
// That's when we want to start listening.
model.ServerListener = this;
}
}
In this object, you can implement the Listener interface and do whatever you’d like in the method.
Here are the steps needed to add a new Callback:
Adding the Callback to the Listener's interface looks the same on both IPlayerModelServerListener
and IPlayerModelClientListener
. We'll add, for example, a request for the server to randomize a loot box and a message telling the client the player got an item, so it can display a notification in the UI.
public interface IPlayerModelServerListener
{
void OnOpenedLootBox();
}
public interface IPlayerModelClientListener
{
void OnGotLootBoxRewardItem(ItemInfo item);
}
public class EmptyPlayerModelServerListener : IPlayerModelServerListener
{
public static readonly EmptyPlayerModelServerListener Instance = new EmptyPlayerModelServerListener();
public void OnOpenedLootBox() { }
}
public class EmptyPlayerModelClientListener : IPlayerModelClientListener
{
public static readonly EmptyPlayerModelClientListener Instance = new EmptyPlayerModelClientListener();
public void OnGotLootBoxRewardItem(ItemInfo item) { }
}
After adding the Callbacks to the listener classes, we'll implement the methods in the classes implementing the Listener interface.
// Server's Listener
public sealed class PlayerActor : PlayerActorBase<PlayerModel>, IPlayerModelServerListener
{
...
// Handles Callback and schedules an action to give the player the randomized loot.
void IPlayerModelServerListener.OnOpenLootBox(ItemInfo item)
{
// Randomize a reward.
ItemId item = RandomizedLootBoxResult();
// Store the result in the player's state as pending.
EnqueueServerAction(new PlayerLootBoxResult(item));
}
}
// Client's Listener
public class ApplicationStateManager :
{
...
void IPlayerModelClientListener.OnGotLootBoxRewardItem(ItemInfo item)
{
// Spawn a popup to celebrate the received item.
LootBoxRewardPopup.Show(item);
}
}
To trigger a call to a Listener, call the correspondent method inside an action or tick.
[ModelAction(ActionCodes.PlayerOpenLootBox)]
public class PlayerOpenLootBox : PlayerAction
{
...
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
player.ServerListener.OnOpenLootBox(itemInfo);
...
}
}
As we defined prior, OnOpenLootBox
enqueues another action to grant the player the item that was just generated on the server. This PlayerLootBoxResult
can, in turn, call the Client Listener's method to show the corresponding pop-up or UI effect we want to achieve.
[ModelAction(ActionCodes. PlayerLootBoxResult)]
public class PlayerLootBoxResult : PlayerSynchronizedServerActionCore<PlayerModel>
{
public ItemId ItemId { get; private set; }
...
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
ItemInfo itemInfo = player.GameConfig.Items[ItemId];
if (player.Items.Contains(ItemId))
return ActionResult.AlreadyHasItem;
if (commit)
{
player.Items.Add(ItemId);
player.ClientListener.OnGotLootBoxRewardItem(itemInfo);
}
return MetaActionResult.Success;
}
}
Since this is shared code, these actions will run on both the client and the server, and the ClientListener
and ServerListener
methods will be called on both. But fear not! This is not an issue because previously we made sure that on the server the Client Listener is empty and vice versa.
Well done! You now have everything you need to test your Callback and respective Listeners. Experiment a bit by adding your Callback to a PlayerAction
and seeing the resulting code trigger.
Be careful, you should not directly execute other actions from within the Listener callbacks as the action execution logic is not re-entrant. ExecuteAction
is client-only code, and therefore cannot be called from shared code, including inside another action. If you want to queue actions on the client, the best way to do it is by tracking some state in the PlayerModel
and using it to determine if and what is the next action to call. On the server, however, it's possible to use EnqueueServerAction()
directly.