Appearance
Appearance
Note
Config-driven Game Events are an alternative to LiveOps Events. If you don't know which one is best for you, see the Introduction to Game Events page for a comparison.
This section will help you get up and running with a basic Happy Hour Event feature. Happy Hour, or any kind of Event, is not a single built-in Metaplay feature, but rather is implemented using various utilities found in the Metaplay SDK. Once the basic Event implementation is in place, it can be easily customized to fit your specific needs.
Let's create a new game config sheet for Happy Hours. 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.
HappyHourId #key | DisplayName | Description | Item | CostFactor | Segments | Schedule.TimeMode | Schedule.Start.Date | Schedule.Start.Time | Schedule.Preview | Schedule.Duration | Schedule.EndingSoon | Schedule.Review | Schedule.Recurrence |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|
FridayHappyHour | Friday Happy Hour | Sword's cost is decreased 75% on Friday evening | Sword | 0.25 | LevelAtLEast5 | Local | 2021-05-28 | 18:00 | 1h | 4h | 30m | 15m | 7d |
This sheet defines just one simple Happy Hour Event, FridayHappyHour
. This Event is enabled to players in the LevelAtLeast5
segment, and decreases the cost of the Sword
item by 75%.
The columns starting with Schedule.
define the Schedule of the Event. This Event first occurs on the 28th of May 2021, and repeats weekly (every 7 days) and indefinitely (NumRepeats is omitted). Each occasion of the Event lasts from 18:00 to 22:00. The Event is visible 1 hour before it starts (preview) and 15 minutes after it ends (review), and is shown as "ending soon" 30 minutes before it ends. The scheduling happens based on each player's local time, rather than UTC.
The Item
s in this Happy Hour guide are just an example. You'll want to adapt them to something appropriate for your game.
Now, let's define the game config data class the Happy Hours will be imported into:
// Typed string identifier for the *Happy Hours*
[MetaSerializable]
public class HappyHourId : StringId<HappyHourId> { }
// This is the game config data class used at runtime.
// At config build time, the HappyHourSourceConfigItem helper class is parsed
// and converted into HappyHourInfo.
[MetaSerializable]
public class HappyHourInfo : IMetaActivableConfigData<HappyHourId>
{
[MetaMember(1)] public HappyHourId HappyHourId { get; private set; }
[MetaMember(2)] public string DisplayName { get; private set; }
[MetaMember(3)] public string Description { get; private set; }
[MetaMember(4)] public ItemInfo Item { get; private set; }
[MetaMember(5)] public F64 CostFactor { get; private set; }
// MetaActivableParams is defined in the Metaplay SDK, and encapsulates
// various parameters common to *Events* and other *Activables*.
[MetaMember(6)] public MetaActivableParams ActivableParams { get; private set; }
public HappyHourId ActivableId => HappyHourId;
public HappyHourId ConfigKey => HappyHourId;
// This would be for showing a bit of additional info in the *LiveOps Dashboard*.
// We have no need for it here, so it's null.
public string DisplayShortInfo => null;
public HappyHourInfo(){ }
public HappyHourInfo(HappyHourId happyHourId, string displayName, string description, ItemInfo item, F64 costFactor, MetaActivableParams activableParams)
{
HappyHourId = happyHourId ?? throw new ArgumentNullException(nameof(happyHourId));
DisplayName = displayName ?? throw new ArgumentNullException(nameof(displayName));
Description = description ?? throw new ArgumentNullException(nameof(description));
Item = item ?? throw new ArgumentNullException(nameof(item));
CostFactor = costFactor;
ActivableParams = activableParams ?? throw new ArgumentNullException(nameof(activableParams));
}
}
Then, let's define a HappyHourSourceConfigItem
class, which helps in translating the config sheet structure into the HappyHourInfo
type:
public class HappyHourSourceConfigItem : IGameConfigSourceItem<HappyHourId, HappyHourInfo>
{
// Fields with trivial mapping to HappyHourInfo fields
public HappyHourId HappyHourId;
public string DisplayName;
public string Description;
public ItemInfo Item;
public F64 CostFactor;
// Fields defining HappyHourInfo.ActivableParams
public List<MetaRef<PlayerSegmentInfoBase>> Segments;
public MetaScheduleBase Schedule;
// This will be called from the game config builder
public HappyHourInfo ToConfigData(GameConfigBuildLog buildLog)
{
return new HappyHourInfo(
happyHourId: HappyHourId,
displayName: DisplayName,
description: Description,
item: Item,
costFactor: CostFactor,
activableParams: new MetaActivableParams(
isEnabled: true,
segments: Segments,
additionalConditions: null,
lifetime: MetaActivableLifetimeSpec.ScheduleBased.Instance,
isTransient: false,
schedule: Schedule,
maxActivations: null,
maxTotalConsumes: null,
maxConsumesPerActivation: null,
cooldown: MetaActivableCooldownSpec.ScheduleBased.Instance,
allowActivationAdjustment: true));
}
}
There are a number of fields in MetaActivableParams
that we're not interested in configuring in the HappyHours
sheet. They are set to sensible default values when constructing the MetaActivableParams
. For example, lifetime
and cooldown
are both Schedule-based, which is usually the defaut choice for anything that has a Schedule.
Now that we have the HappyHours
sheet and the came config data class to represent it, let's add the code to import the sheet.
First, a new config library member in SharedGameConfig
:
public class SharedGameConfig : SharedGameConfigBase
{
[GameConfigEntry("HappyHours")]
[GameConfigEntryTransform(typeof(HappyHourSourceConfigItem))]
public GameConfigLibrary<HappyHourId, HappyHourInfo> HappyHours { get; private set; }
}
If you haven't defined a custom GameConfigBuild
-derived class, the SDK builds all libraries that appear in your SharedGameConfig
class, so you don't need to introduce HappyHours
to the builder explicitly.
If you have a custom GameConfigBuild
-derived class, consider if you need to update it to build the HappyHours
library.
Then, it should be possible to build the Game Configs, including the HappyHours
config.
There will need to be some state in PlayerModel
to deal with the Happy Hour Event activation bookkeeping. Most of the functionality for that is implemented in the utilities for Activables in the Metaplay SDK, and here we'll just define a few simple classes.
First, define the state of a single Happy Hour for a player:
[MetaSerializableDerived(2)]
public class HappyHourModel : MetaActivableState<HappyHourId, HappyHourInfo>
{
[MetaMember(1)] public sealed override HappyHourId ActivableId { get; protected set; }
// Just a convenience shorthand.
[IgnoreDataMember] public HappyHourInfo Info => ActivableInfo;
HappyHourModel(){ }
public HappyHourModel(HappyHourInfo info)
: base(info)
{
}
}
Next, a containing class to hold the state for all the Happy Hours for a player:
[MetaSerializableDerived(2)]
public class PlayerHappyHoursModel : MetaActivableSet<HappyHourId, HappyHourInfo, HappyHourModel>
{
protected override HappyHourModel CreateActivableState(HappyHourInfo info, IPlayerModelBase player)
{
return new HappyHourModel(info);
}
}
Then, add a PlayerHappyHoursModel
member to your PlayerModel
:
public class PlayerModel : ...
{
...
[MetaMember(201)] public PlayerHappyHoursModel HappyHours { get; private set; } = new PlayerHappyHoursModel();
...
}
We've now defined game config data types and the player state for Happy Hours, but the code doesn't yet actually do anything with them.
The next step is to add code that activates new Happy Hour Events when their conditions become fulfilled; that is, when the player belongs to the Segment and the Schedule is active. After a Happy Hour has been activated, it will stay active (that is, visible to the player and affecting the item cost) until the end of the Schedule occasion is reached.
The simplest way to activate new Events is to do it continuously, in PlayerModel.GameTick
:
public class PlayerModel : ...
{
...
protected override void GameTick(IChecksumContext checksumCtx)
{
...
HappyHours.TryStartActivationForEach(GameConfig.HappyHours.Values, player: this);
}
}
We now have Happy Hours that activate according to the configured Segments and Schedules. Let's now add implement the meat of the feature, item cost reduction during a Happy Hour.
Note that since the "items" in this guide are just an example, you'll need to adapt this to something more appropriate to your game.
Let's assume the game has a pre-existing PlayerAction
for purchasing items with an in-game gold currency, which looked like this before any Happy Hour modifications:
[ModelAction(ActionCodes.PlayerPurchaseItem)]
public class PlayerPurchaseItem : PlayerAction
{
public ItemTypeId ItemType { get; private set; }
PlayerPurchaseItem() { }
public PlayerPurchaseItem(ItemTypeId itemType) { ItemType = itemType; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
ItemInfo itemInfo = player.GameConfig.Items[ItemType];
int goldCost = itemInfo.PurchaseGoldCost;
// Must have enough gold to purchase
if (player.Wallet.NumGold < goldCost)
return ActionResult.NotEnoughResources;
if (commit)
{
// Apply change
player.AddItem(itemInfo);
player.Wallet.NumGold -= goldCost;
}
return ActionResult.Success;
}
}
But now, the Execute
method needs to compute the cost factor from active Happy Hours:
[ModelAction(ActionCodes.PlayerPurchaseItem)]
public class PlayerPurchaseItem : PlayerAction
{
//Properties and constructors stay the same
...
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
ItemInfo itemInfo = player.GameConfig.Items[ItemType];
// Collect all active *Happy Hours* affecting the cost of this item
IEnumerable<HappyHourInfo> relevantHappyHourInfos =
player.HappyHours.GetActiveStates(player)
.Select(state => state.Info)
.Where(happyHourInfo => happyHourInfo.Item.Id == ItemType);
// Apply cost factor from all of those *Happy Hours*
F64 costFactor = F64.One;
foreach (HappyHourInfo info in relevantHappyHourInfos)
costFactor *= info.CostFactor;
int goldCost = F64.RoundToInt(costFactor * itemInfo.PurchaseGoldCost);
... the rest stays the same ...
}
}
We now have the meat of the implementation done. Now we need to wire the Happy Hours to the game client's UI. First, let's make the item purchasing UI reflect the modified costs when Happy Hours are active.
Again, the snippets below are just simple examples, created for this Happy Hour Event. You can adapt them as appropriate for your game.
The item purchasing UI can compute the modified costs in the same manner as the PlayerPurchaseItem
action in Step 6: Implement Item Cost Reduction During the Happy Hour:
// For this example, we assume this script manages the per-item
// ItemShopSingleItemScript game objects.
Dictionary<ItemTypeId, ItemShopSingleItemScript> _itemObjects;
void Start()
{
... instantiate individual item objects, initialize _itemObjects ...
}
void Update()
{
PlayerModel player = MetaplayClient.PlayerModel;
// Resolve all active *Happy Hours*
List<HappyHourInfo> activeHappyHourInfos =
player.HappyHours.GetActiveStates(player)
.Select(state => state.Info)
.ToList();
// Resolve cost factors for all items affected by active *Happy Hours*
Dictionary<ItemTypeId, F64> itemCostFactors = new Dictionary<ItemTypeId, F64>();
foreach (HappyHourInfo happyHourInfo in activeHappyHourInfos)
{
if (!itemCostFactors.ContainsKey(happyHourInfo.Item.Id))
itemCostFactors.Add(happyHourInfo.Item.Id, F64.One);
itemCostFactors[happyHourInfo.Item.Id] *= happyHourInfo.CostFactor;
}
// Set costs on individual item game objects
foreach (ItemInfo itemInfo in player.GameConfig.Items.Values)
{
ItemShopSingleItemScript itemObject = _itemObjects[itemInfo.Id];
if (itemCostFactors.TryGetValue(itemInfo.Id, out F64 costFactor))
{
itemObject.Cost = F64.RoundToInt(costFactor * itemInfo.PurchaseGoldCost);
itemObject.DiscountText = "On sale!";
}
else
{
itemObject.Cost = itemInfo.PurchaseGoldCost;
itemObject.DiscountText = "";
}
}
}
We'll also want a dedicated UI for visualizing upcoming, ongoing, and recently-ended Events.
MetaActivableSet
contains a method TryGetVisibleStatus
, which returns information about the status of Events that should be visualized to the player. In particular, it tells whether the Event is in preview, active, or in review. This method is useful for Event UI code:
// For this example, we assume this script manages the per-event
// EventListItemScript game objects.
public EventListItemScript EventListItem;
Dictionary<HappyHourId, EventListItemScript> _eventUIItems;
void Start()
{
// Create a GameObject for each configured *Happy Hour*
PlayerModel player = MetaplayClient.PlayerModel;
_eventUIItems = new Dictionary<HappyHourId, EventListItemScript>();
foreach (HappyHourInfo info in player.GameConfig.HappyHours.Values)
{
EventListItemScript uiItem = Instantiate(EventListItem, transform);
_eventUIItems.Add(info.HappyHourId, uiItem);
}
}
void Update()
{
PlayerModel player = MetaplayClient.PlayerModel;
// Update each per-event UI item acccording to
// the event's MetaActivableVisibleStatus.
foreach ((HappyHourId happyHourId, EventListItemScript eventUIItem) in _eventObjects)
{
HappyHourInfo happyHourInfo = player.GameConfig.HappyHours[happyHourId];
// Resolve the event's visible status
MetaActivableVisibleStatus visibleStatus;
player.HappyHours.TryGetVisibleStatus(happyHourInfo, player, out visibleStatus);
if (visibleStatus == null)
{
// The event is not active or in other visible state. Hide it.
eventUIItem.gameObject.SetActive(false);
}
else
{
// Event is in a visible state. Display it.
eventUIItem.gameObject.SetActive(true);
// Note: In reality the texts should be localized.
int salePercentage = F64.FloorToInt((F64.One - eventInfo.CostFactor) * 100);
eventUIItem.TitleText.text = happyHourId.ToString();
eventUIItem.InfoText.text = $"{happyHourInfo.Item.Id} on sale, {salePercentage}% off!";
// Set UI status text according to the specific status.
switch (visibleStatus)
{
case MetaActivableVisibleStatus.InPreview preview:
{
MetaDuration untilStart = preview.ScheduleEnabledRange.Start - player.CurrentTime;
uiItem.StatusText.text = $"Starting soon: {untilStart.ToSimplifiedString()}";
break;
}
case MetaActivableVisibleStatus.Active active:
{
MetaDuration? remaining = active.ActivationEndsAt - player.CurrentTime;
uiItem.StatusText.text = $"Time left: {remaining?.ToSimplifiedString()}";
break;
}
case MetaActivableVisibleStatus.EndingSoon endingSoon:
{
MetaDuration? remaining = endingSoon.ActivationEndsAt - player.CurrentTime;
uiItem.StatusText.text = $"Ending soon! Time left: {remaining?.ToSimplifiedString()}";
break;
}
case MetaActivableVisibleStatus.InReview review:
{
uiItem.StatusText.text = "This event has ended. The item is back to its normal cost.";
break;
}
default:
{
// Unhandled status
uiItem.StatusText.text = $"{visibleStatus?.GetType().Name}";
break;
}
}
}
}
}
At this point, the Happy Hour feature should already be functioning, ignoring any remaining game-specific integration work, such as client UI work.
As a final touch, let's declare Happy Hours as a kind of Activable by adding a few attributes. The attributes will allow the Metaplay SDK to locate the configs and player state of the Happy Hours, so that it can visualize them in the LiveOps Dashboard without requiring further game-specific code.
First, a metadata attribute is needed to declare the Happy Hours kind of Activables:
[MetaActivableKindMetadata(
id: "HappyHour",
displayName: "Happy Hour",
description: "Simple events that reduce item cost",
categoryId: "Event")]
public static class ActivableKindMetadataHappyHour { }
Activable "kinds" are further grouped into "categories". Imagine you had multiple different kinds of Events in addition to Happy Hours; they might all be in the same Event
category. The category declaration is done with a similar attribute as above:
// If the Event category is later expanded to
// contain other than Happy Hours, this could be moved elsewhere.
[MetaActivableCategoryMetadata(
id: "Event",
displayName: "In-Game Events",
description: "Scheduled and targeted in-game events")]
public static class ActivableCategoryMetadataEvent { }
An attribute is needed on the HappyHourInfo
class:
[MetaSerializable]
[MetaActivableConfigData("HappyHour")] // Add this
public class HappyHourInfo : IMetaActivableConfigData<HappyHourId>
{
...
}
And an attribute on the PlayerHappyHoursModel
class:
[MetaSerializableDerived(2)]
[MetaActivableSet("HappyHour")] // Add this
public class PlayerHappyHoursModel : MetaActivableSet<HappyHourId, HappyHourInfo, HappyHourModel>
{
...
}
The LiveOps Dashboard contains useful visualizations of Events that will help in validating that the Happy Hours are functioning properly.
Note that whether the dashboard says "In-Game Events" or something else depends on what you gave as the displayName
argument to MetaActivableCategoryMetadata
in Step 9: Declare the Happy Hours as a Kind of Activable.
The In-Game Events page shows all configured Events:
Clicking on View Event on an individual item will show detailed information about the scheduling status and configuration of the individual Event:
On the Manage Player page, in the Segments & Targeting tab, an In-Game Events card shows the player-specific state of each Event:
The Happy Hour feature in the Quick Implementation Guide is built using general-purpose utilities from the Metaplay SDK. The feature can be customized and extended while still leveraging the same utilities. This section describes some possible customizations.
The example HappyHours
sheet contains just one item. Here are further options for the Schedule that aren't illustrated in the example sheet:
Local
, the TimeMode
can be Utc
. This will make the Schedule be evaluated according to the UTC time instead of the player's local time.Preview
, Duration
, EndingSoon
, Review
, and Recurrence
) can use the units y
, mo
, d
, h
, m
, and s
for years, months, days, hours, minutes, and seconds, respectively.Recurrence
can use a combination of multiple units, such as 1mo 5d
. Recurrence
can use at most one unit.Recurrence
can be omitted, specifying a non-recurring Schedule.Preview
, EndingSoon
, and Review
can be omitted. An omitted period is zero-length.Duration
), units are allowed to be negative, which can be useful when varying-duration periods (year or month) are involved. For example, a Duration
of 1mo -5d
represents "5 days less than a month", which when combined with a Start.Date
of 2021-11-01
can be used to represent a schedule that starts at the beginning of the month and ends 5 days before the end of the month.NumRepeats
is an integer specifying how many repeated occasions the Schedule contains, including the first one. NumRepeats
can be omitted, meaning unlimited repetitions.The example Happy Hour feature is a simple one that only affects gameplay while it is active. For different kinds of Events, you might want to perform some actions at the end of the Event.
For example, imagine an Event where the goal for the player is to upgrade some special building to a target level. If the player manages to reach that target level by the end of the Event, they get a reward. For this purpose, a "finalization" step is supported for Activables:
[MetaSerializableDerived(3)]
public class SpecialBuildingEventModel : MetaActivableState
{
[MetaMember(1)] public SpecialBuildingEventInfo Info { get; private set; }
SpecialBuildingEventModel(){ }
public SpecialBuildingEventModel(SpecialBuildingEventInfo info){ Info = info; }
// The virtual Finalize method is for defining custom actions
// to run after an activation has ended.
protected override void Finalize(IPlayerModelBase player)
{
PlayerModel player = (PlayerModel)playerBase;
bool hasReachedTarget = player.Buildings.ContainsKey(Info.Building.Id)
&& player.Buildings[Info.Building.Id] >= Info.TargetLevel;
if (hasReachedTarget)
{
player.Log.Info("Finalizing building event {EventId}, giving rewards", Info.EventId);
foreach (PlayerReward reward in Info.Rewards)
reward.Consume(player, source: null);
}
else
player.Log.Info("Finalizing building event {EventId}, but target level wasn't reached", Info.EventId);
}
}
And to trigger the finalization, this TryFinalizeEach
line would to be added to PlayerModel.GameTick
:
public class PlayerModel : ...
{
...
protected override void GameTick(IChecksumContext checksumCtx)
{
...
// This TryFinalizeEach call triggers the Finalize methods for
// events that have ended but haven't yet been finalized.
SpecialBuildingEvents.TryFinalizeEach(GameConfig.SpecialBuildingEvents.Values, player: this);
// Activation is done after finalization to ensure that finalization
// rewards are given before a new event is potentially started.
SpecialBuildingEvents.TryStartActivationForEach(GameConfig.SpecialBuildingEvents.Values, player: this);
...
}
}
The Idler reference project contains a "special producer" Event similar to this, which can be used as an example.
During development, it's convenient to forcibly activate Events and ignore their configured Schedules and other conditions. The Metaplay SDK has development functionality for forcing Activables into specific phases, such as "active" and "review". This functionality is available via the Unity Editor client as well as the LiveOps Dashboard.
DANGER
Note: Forcing an Activable into a phase bypasses its configuration, which may cause unexpected behavior in code that assumes the configuration is always obeyed. Therefore, this functionality is only available in development environments, and should only be used for development-time purposes such as testing and debugging.
In Unity Editor, while the game is running, inspect the MetaplaySDK
object in the scene (or any other object that has MetaplaySDKBehavior
). The "Activables debug-forcing" section allows forcing an Activable into any of the visible phases, as well as removing a forced phase to return the Activable to its normal configured behavior:
The same functionality is also available on the Manage Player page in the LiveOps Dashboard: