Appearance
Appearance
A working implementation of a LiveOps Event - This builds on top of the event that was implemented in Implementing LiveOps Events. Even if you didn't follow the tutorial, we recommend that you familiarize yourself with how the implementation works so you can follow this guide more easily.
This page describes some useful ways to further integrate LiveOps events into your gameplay loop. This includes listener-based polling for better performance, having the player interactively claim rewards (instead of having them automatically enter their inventory), and having popups to warn about new events as well as tracking the time left for them to start or end.
In the Implementing LiveOps Events page, we used naive polling every frame to refresh the UI according to the events' states in the PlayerModel
. A more efficient pattern is to initialize the UI once and afterward refresh it only when something changes in the events.
To react to these changes, there is a callback, GotLiveOpsEventUpdate(PlayerLiveOpsEventModel)
, in the IPlayerModelClientListenerCore
interface. You can use this callback in your implementation of IPlayerModelClientListenerCore
and refresh your event UI based on that. GotLiveOpsEventUpdate()
is called when the client receives any kind of event update from the server:
LiveOpsEventContent.AudienceMembershipIsSticky
to true
- see the Targeting section of the Configuring LiveOps Events page.For general information about client-side model listeners, see Client and Server Listeners. The builtin SDK-side listeners (such as IPlayerModelClientListenerCore
) work the same way as the custom listeners described in that document, but are assigned to the SDK-side member ClientListenerCore
in MetaplayClient.PlayerModel
, instead of ClientListener
.
In the event implemented in Implementing LiveOps Events, the game logic code automatically claims the event's rewards when it ends. More realistically, you'd probably want to show the event's results to the player and have a button for the player to acknowledge and claim the rewards.
To implement this, we need to do a few things:
Concluded
phase. Just keep the result in the ButtonClickingEventModel
.ButtonClickingEventModel
does not get removed from PlayerModel
until its rewards have been claimed, even if the event has ended.To this end, there are a few important bits in the event API provided by the SDK:
bool AllowRemove
property in PlayerLiveOpsEventModel
to tell the SDK if it's OK to remove an event. By default, this is always true
.OnLatestUpdateAcknowledged()
method in PlayerLiveOpsEventModel
which can be invoked by clients via the PlayerClearLiveOpsEventUpdates
action. This method can be used for various claiming and acknowledging functionality.Here's a quick continuation of the previous guide's version to have the player actively claim the rewards.
First, let's modify ButtonClickingEventModel
:
public class ButtonClickingEventModel : ...
{
...
// Keep track of whether player has claimed the result
// (whether or not the player reached the target to get rewards).
[MetaMember(2)] public bool ResultClaimed { get; private set; } = false;
// This tells the SDK to not remove this ButtonClickingEventModel
// from PlayerModel.LiveOpsEvents until the player has claimed the result.
public override bool AllowRemove => ResultClaimed;
...
protected override void OnPhaseChanged(PlayerModel player, LiveOpsEventPhase oldPhase, LiveOpsEventPhase[] fastForwardedPhases, LiveOpsEventPhase newPhase)
{
// In this example, we've now moved the reward claiming from here
// to OnLatestUpdateAcknowledged.
// In our simple feature, nothing remains to be done here.
// A more complex feature may still find use for this method,
// to do some changes to the event's or player's state when the
// event's phase changes.
}
// This gets called when the client invokes the PlayerClearLiveOpsEventUpdates action.
// In this case we use it to communicate that the player wants to claim the reward.
protected override void OnLatestUpdateAcknowledged(PlayerModel player)
{
if (Phase.IsEndedPhase() && !ResultClaimed)
{
if (NumClicksDone >= Content.NumClicksRequired)
{
player.Log.Debug("Player reached {NumClicks}/{NumClicksRequired} clicks during event, granting rewards (event {EventId})", NumClicksDone, Content.NumClicksRequired, Id);
foreach (MetaPlayerRewardBase reward in Content.Rewards)
reward.InvokeConsume(player, source: null);
}
else
player.Log.Debug("Player reached only {NumClicks}/{NumClicksRequired} clicks during event, not granting rewards (event {EventId})", NumClicksDone, Content.NumClicksRequired, Id);
ResultClaimed = true;
}
}
}
Then, add a claim button to the event UI:
public class EventListItemScript : MonoBehaviour
{
...
public TextMeshProUGUI ResultText;
public Button ClaimButton;
void Update()
{
...
if (eventModel is ButtonClickingEventModel buttonEvent)
{
...
// Update the result text and claim button according to whether the result is available.
if (buttonEvent.Phase.IsEndedPhase() && !buttonEvent.ResultClaimed)
{
ClaimButton.gameObject.SetActive(true);
ResultText.gameObject.SetActive(true);
if (buttonEvent.NumClicksDone >= buttonEvent.Content.NumClicksRequired)
ResultText.text = "You reached the target, congratulations!";
else
ResultText.text = "You didn't reach the target, better luck next time!";
}
else
{
ClaimButton.gameObject.SetActive(false);
ResultText.gameObject.SetActive(false);
}
}
}
// Wire ClaimButton to this method
public void OnClaimClicked()
{
// We claim the result using PlayerClearLiveOpsEventUpdates,
// which ends up calling ButtonClickingEventModel.OnLatestUpdateAcknowledged().
MetaplayClient.PlayerContext.ExecuteAction(new PlayerClearLiveOpsEventUpdates(new List<MetaGuid> { EventId }));
}
}
All done! Now the player needs to click the ClaimButton
to get the rewards.
Let's say you want to show a popup to the player when a new event starts. You can do that by keeping track of whether the player has seen the popup in your event model, and overriding OnLatestUpdateAcknowledged()
to set the flag when appropriate. On the client, if the event is active and the popup hasn't been shown yet, show the popup and invoke the PlayerClearLiveOpsEventUpdates
action.
Note that these are the same OnLatestUpdateAcknowledged()
and PlayerClearLiveOpsEventUpdates
that are used in the Interactive Reward Claiming section. They can be used for several purposes at the same time, but this example only shows the popup functionality.
public class ButtonClickingEventModel : ...
{
...
[MetaMember(3)] public bool EventStartPopupSeen { get; private set; } = false;
// This gets called when the client invokes the PlayerClearLiveOpsEventUpdates action.
// In this case we use it to communicate that the player has seen the event start popup.
protected override void OnLatestUpdateAcknowledged(PlayerModel player)
{
if (Phase.IsActivePhase() && !EventStartPopupSeen)
EventStartPopupSeen = true;
... // possible other acknowledging functionality you may have (e.g. reward claiming)
}
}
The following example client code triggers a popup for each new event. The example assumes you have a mechanism for displaying popups, that would be activated by the IsShowingPopup()
and ShowPopup()
methods.
void UpdatePopups()
{
// If already showing popup, wait for user to close it before showing another.
if (IsShowingPopup())
return;
// Find a button click event to show a popup for, if any.
// TryGetEarliestUpdate is a helper for getting a matching event
// that has the least recent unacknowledged update.
// We give it a filter function to find events that are due for a popup.
ButtonClickingEventModel buttonEvent =
(ButtonClickingEventModel)MetaplayClient.PlayerModel.LiveOpsEvents.TryGetEarliestUpdate(
eventModel => eventModel is ButtonClickingEventModel buttonEvent
&& buttonEvent.Phase.IsActivePhase()
&& !buttonEvent.EventStartPopupSeen);
// If no matching events were found, don't do anything.
if (buttonEvent == null)
return;
// Show the popup, and clear the update to mark the popup as seen.
// PlayerClearLiveOpsEventUpdates ends up calling OnLatestUpdateAcknowledged()
// which we implemented in the previous example snippet.
ShowPopup($"Button-clicking event is active! Try to click the button {buttonEvent.Content.NumClicksRequired} times.");
MetaplayClient.PlayerContext.ExecuteAction(new PlayerClearLiveOpsEventUpdates(new List<MetaGuid> { buttonEvent.Id }));
}
Note that while the OnLatestUpdateAcknowledged()
/PlayerClearLiveOpsEventUpdates
mechanism is provided by the SDK with the aim of being generally useful, they are not fundamentally necessary, and you could alternatively implement this acknowledgment with your own custom PlayerAction
if needed.
When the player joins an event, the server informs the client of the event's schedule. The client UI can use this to show a "time remaining" indicator. The schedule is available in PlayerLiveOpsEventModel.ScheduleMaybe
. It is null if the event doesn't have a schedule.
Note that even though the client is made aware of the schedule, it should not assume the event phase transitions will happen exactly at the indicated timestamps. This is because the event is fundamentally server-driven, and there are various delays involved, including server-internal polling intervals and client-server network latencies. Therefore, the client UI should tolerate the case where a phase's start time is in the past even though the event hasn't yet entered the phase. Currently, the discrepancy can be up to roughly 30 seconds.
As an example, we'll augment the event UI from the Implementing LiveOps Events page to show the time until the event starts (when it's still in the preview phase) or ends (if the event is already running).
public class EventListItemScript : MonoBehaviour
{
...
public TextMeshProUGUI TimeRemainingText;
void Update()
{
...
if (eventModel.ScheduleMaybe == null)
{
// Event doesn't have a schedule, so don't say anything.
TimeRemainingText.gameObject.SetActive(false);
}
else if (eventModel.Phase == LiveOpsEventPhase.Preview)
{
TimeRemainingText.text = $"Event will start in {TimeUntilText(eventModel.ScheduleMaybe.GetEnabledStartTime())}";
TimeRemainingText.gameObject.SetActive(true);
}
else if (eventModel.Phase.IsActivePhase())
{
TimeRemainingText.text = $"Event will end in {TimeUntilText(eventModel.ScheduleMaybe.GetEnabledEndTime())}";
TimeRemainingText.gameObject.SetActive(true);
}
else
{
// Event has a schedule, but it has already ended (is neither in Preview or an active phase).
TimeRemainingText.gameObject.SetActive(false);
}
...
}
string TimeUntilText(MetaTime targetTime)
{
MetaDuration duration = targetTime - MetaplayClient.PlayerModel.CurrentTime;
// The target time can be in the past, because the event phase changes
// are server-driven and do not happen instantly.
// You can choose to indicate this situation by clamping to zero like here,
// or in some other way.
duration = Util.Max(MetaDuration.Zero, duration);
return duration.ToSimplifiedString();
}
}