Appearance
Appearance
MetaRewards
implemented - Dynamic Purchases depend on MetaRewards
. See Implementing MetaRewards for more details.After implementing simple static-content In-App Purchases there are other interesting features that can be added to make your game more exciting and add functionality to your marketplace.
Being able to offer Dynamic Products means you can better tailor the game experience to each player's situation. You could for example have a Product with goods that scale or change depending on the player’s level or on what item they need the most. Having your shop keep up with seasonal events and in-game lore can be made much easier if you have reusable Dynamic IAPs as a feature.
On this page, we also talk about registering Resolved Content for the In-App Purchases. That means that you’ll have detailed accounts of what goods were received through every IAP even if your game configs change and your productIds
are now associated with different content. This is useful for many reasons including having better tools for customer support.
Players' completed Purchases are persistently recorded and can be viewed in the LiveOps Dashboard, as shown in the Perform a Test Purchase section of the Getting Started with In-App Purchases. By default, the records contain the InAppProductId
of the purchased Product, but not the concrete contents that were granted to the player.
Knowing the concrete purchased contents of historical Purchases is useful for customer support. However, the contents cannot necessarily be determined based solely on the InAppProductId
, since the game configs might've changed since the Purchase was made. For this reason, the Metaplay SDK supports explicitly recording them as part of each Purchase event, calling this concept Resolved Purchase Content.
Let's implement Resolved Purchase Contents on top of the example Products in the Getting Started with In-App Purchases. First, implement a game-specific derived class of ResolvedPurchaseContentBase
which reflects the Purchase contents in the InAppProductInfo
class:
[MetaSerializableDerived(1)]
public class ResolvedInAppProductContent : ResolvedPurchaseContentBase
{
[MetaMember(1)] public int NumGems;
ResolvedInAppProductContent() { }
public ResolvedInAppProductContent(int numGems)
{
NumGems = numGems;
}
}
Then, augment PlayerModel.OnClaimedInAppProduct
to assign this to the resolvedContent
output:
// PlayerModel.cs
public override void OnClaimedInAppProduct(
InAppPurchaseEvent purchaseEvent,
InAppProductInfoBase productInfoBase,
out ResolvedPurchaseContentBase resolvedContent)
{
// This part is the same as previously:
InAppProductInfo productInfo = (InAppProductInfo)productInfoBase;
// Add the contents of the product to the PlayerModel.
NumGems += productInfo.NumGems;
// This is the new part:
// Assign the resolved content based on the current config.
resolvedContent = new ResolvedInAppProductContent(productInfo.NumGems);
}
Finally, to define a dashboard visualization for the custom ResolvedInAppProductContent
type, register it in your gameSpecific.ts
:
initializationApi.addInAppPurchaseContents([
{
$type: 'Game.Logic.ResolvedInAppProductContent',
getDisplayContent: (item) => [`${item.numGems} Gems`]
}
])
Now, new Purchases' Resolved Contents will be visible in the LiveOps Dashboard:
The Getting Started with In-App Purchases page describes products whose contents are defined directly in the game configs. That is, the contents granted to the player are static in the sense that they depend only on the configuration of the product at the time of the Purchase.
Certain game features benefit from being able to decide the contents of a Purchase more dynamically. For example, if your game has personalized products whose contents are based on the player's state, those contents cannot be directly specified in the game configs.
💡 Dynamic purchases
The Metaplay SDK comes with an in-game offer feature, which uses Dynamic Purchases. If you're looking to implement offers, see Getting Started with In-Game Offers.
Dynamic-Content Products differ from basic products in the following ways:
InAppProducts
config does not specify the contents of such products but instead specifies that dynamic contents should be used instead.PlayerAction
which prepares the Dynamic Purchase by determining the dynamic content available to the player and assigning it as the product's pending content.PlayerAction
) in order to persist the information about the pending dynamic content for the Purchase.The following sub-sections describe the steps needed to implement a Dynamic-Content Product. The examples assume a simple imaginary "dynamic chest" game feature, where a dynamic amount of gold is offered to players. The examples don't fully implement the personalized product feature but only show the parts relevant to the Dynamic Purchase mechanism.
Let's augment the InAppProducts
sheet from Getting Started with In-App Purchases with Dynamic Products:
ProductId #key | Name | Price | HasDynamicContent | NumGems | DevelopmentId | GoogleId | AppleId |
---|---|---|---|---|---|---|---|
GemPack1 | Gem Pack | 0.99 | false | 50 | dev.gempack_1 | com.examplecompany.examplegame.gempack_1 | com.examplecompany.examplegame.GemPack_1 |
CheapDynamicChest | Cheap Dynamic Chest | 1.99 | true | dev.cheapdynamicchest | com.examplecompany.examplegame.cheapdynamicchest | com.examplecompany.examplegame.CheapDynamicChest | |
ExpensiveDynamicChest | Expensive Dynamic Chest | 4.99 | true | dev.expensivedynamicchest | com.examplecompany.examplegame.expensivedynamicchest | com.examplecompany.examplegame.ExpensiveDynamicChest |
Note the new HasDynamicContent
column. It is an SDK built-in column, and defaults to false if omitted.
Next, let's define the content that gets associated with each Dynamic Purchase. The type must derive from DynamicPurchaseContent
and be able to represent the player-granted resources as MetaReward
s.
// DynamicChest.cs
[MetaSerializableDerived(1)]
public class DynamicChestPurchaseContent : DynamicPurchaseContent
{
[MetaMember(1)] public DynamicChestId ChestId;
[MetaMember(2)] public int NumGold;
DynamicChestPurchaseContent(){ }
public DynamicChestPurchaseContent(DynamicChestId chestId, int numGold)
{
ChestId = chestId;
NumGold = numGold;
}
// The Metaplay SDK uses the PurchaseRewards property to grant
// the dynamic contents to the player after the purchase has
// been validated.
public override List<MetaPlayerRewardBase> PurchaseRewards =>
new List<MetaPlayerRewardBase> { new RewardGold(NumGold) };
// OnPurchased is called by the SDK after the purchase has been
// validated. It should handle any on-purchase functionality
// other than granting the rewards.
public override void OnPurchased(IPlayerModelBase playerBase)
{
PlayerModel player = (PlayerModel)playerBase;
player.RerollDynamicChest(ChestId);
}
}
If you've implemented Recording Resolved Purchase Contents, you should adjust PlayerModel.OnClaimedInAppProduct
to accommodate for Dynamic-Content Purchases, such that it only outputs the Resolved Content if any static rewards exist:
// PlayerModel.cs
public override void OnClaimedInAppProduct(
InAppPurchaseEvent purchaseEvent,
InAppProductInfoBase productInfoBase,
out ResolvedPurchaseContentBase resolvedContent)
{
...
if (productInfo.NumGems != 0)
resolvedContent = new ResolvedInAppProductContent(productInfo.NumGems);
else
resolvedContent = null;
}
The SDK automatically records the Resolved Contents of Dynamic-Content Purchases based on the PurchaseRewards
property of the DynamicPurchaseContent
.
Since the purchased content is dynamically generated, there needs to be a PlayerAction
that generates the content and saves it in PlayerModel
.
The following example assumes that there's a DynamicChestInfo
config class that contains a MetaRef<InAppProductInfo>
config reference to the chest's Dynamic-Content Product (such as CheapDynamicChest
or ExpensiveDynamicChest
from the example sheet). It also assumes PlayerModel
has methods DynamicChestIsAvailable
and GetDynamicChestCurrentNumGold
for checking the availability and current amount of gold offered to the player.
// DynamicChest.cs
[ModelAction(ActionCodes.PrepareDynamicChestPurchase)]
public class PrepareDynamicChestPurchase : PlayerAction
{
public DynamicChestInfo ChestInfo;
PrepareDynamicChestPurchase(){ }
public PrepareDynamicChestPurchase(DynamicChestInfo chestInfo) { ChestInfo = chestInfo; }
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
// Check that the chest is available for this player.
// This is just an example check - any custom validation could be done here.
if (!player.DynamicChestIsAvailable(ChestInfo))
return ActionResult.DynamicChestNotAvailable;
// Construct a DynamicChestPurchaseContent with the appropriate gold amount.
DynamicPurchaseContent purchaseContent = new DynamicChestPurchaseContent(
chestId: ChestInfo.ChestId,
numGold: player.GetDynamicChestCurrentNumGold(ChestInfo));
// Get the in-app product configured for this chest.
// It should be a product with HasDynamicContent=true.
InAppProductInfo inAppProduct = ChestInfo.InAppProduct.Ref;
// Check that a new pending dynamic content for the product can currently
// be assigned. In particular, this checks that there is no ongoing
// purchase with the same product.
if (!player.CanSetPendingDynamicInAppPurchase(inAppProduct, purchaseContent, out string errorMessage))
{
player.Log.Warning("{Action}: {Error}", nameof(PrepareDynamicChestPurchase), errorMessage);
return MetaActionResult.CannotSetPendingDynamicPurchase;
}
if (commit)
{
// Assign the dynamic content as the product's pending content.
// On the server, this will cause the player's state to be persisted,
// so that the game will remember the dynamic content even if the
// server crashes.
// Before initiating the actual IAP purchase, the client needs to wait
// until the server has confirmed this assignment.
player.SetPendingDynamicInAppPurchase(
inAppProduct,
purchaseContent,
gameProductAnalyticsId: ChestInfo.ChestId.ToString(),
gameAnalyticsContext: null);
}
return MetaActionResult.Success;
}
}
For the static-content Purchases implemented in Getting Started with In-App Purchases, the code simply calls IAPManager.TryBeginPurchaseProduct
when the "purchase" button is clicked. For Dynamic Purchases, the code instead invokes the PrepareDynamicChestPurchase
as defined above, and tells IAPManager
to wait until the server has confirmed the dynamic content and only then initiate the actual Purchase.
// DynamicChestScript.cs
public void OnClickBuy()
{
// Invoke the action which assigns the pending dynamic purchase content.
MetaplayClient.PlayerContext.ExecuteAction(
new PrepareDynamicChestPurchase(ChestInfo));
// Tell IAPManager to start tracking the pending dynamic content.
// After the server has confirmed the pending dynamic content, IAPManager
// will call TryBeginPurchaseProduct.
InAppProductId productId = ChestInfo.InAppProduct.Ref.ProductId;
MetaplayClient.IAPManager.RegisterPendingDynamicPurchase(productId);
}
If you have multiple places in the code where you invoke such actions, you can avoid explicitly calling RegisterPendingDynamicPurchase
in each place by instead implementing IPlayerModelClientListenerCore.PendingDynamicPurchaseContentAssigned
and calling RegisterPendingDynamicPurchase
from there.
Finally, to define a dashboard visualization for the custom DynamicPurchaseContent
type, register it in your gameSpecific.ts
:
initializationApi.addInAppPurchaseContents([
{
$type: 'Game.Logic.DynamicChestPurchaseContent',
getDisplayContent: (item) => [`Chest in ${item.chestId}`]
}
])
Now, Dynamic Purchase contents will be indicated in the LiveOps Dashboard:
Whenever a player makes a Purchase, an event gets sent to analytics with information about it, such as ProductId
and the Product’s contents. Depending on the game, there might be game-specific information relating to the Purchase Context. That could mean, for example, if the Purchase was made through the standard shop UI or a pop-up for a seasonal item. In a general sense, this could be used to augment the analytics events with any arbitrary data that is desirable.
Without a custom Context, an analytics event payload produced by a (static-content) Purchase looks something like this:
{
"productId": "GemPack1",
"platform": "Development",
"transactionId": "fakeTxn775879",
"platformProductId": "dev.gempack_1",
"referencePrice": 0.9899999999906868,
"gameProductAnalyticsId": null,
"purchaseContext": null,
"resolvedContent": {
"numGems": 50
},
"resolvedDynamicContent": null
}
We wish to change the payload to be like this:
{
"productId": "GemPack1",
"platform": "Development",
"transactionId": "fakeTxn40003",
"platformProductId": "dev.gempack_1",
"referencePrice": 0.9899999999906868,
"gameProductAnalyticsId": "GemPack1",
"purchaseContext": {
"placement": "ShopUI"
},
"resolvedContent": {
"numGems": 50
},
"resolvedDynamicContent": null
},
In this example, the Context is used to convey information about the “placement” where the Purchase was made.
To start, let’s first define a custom class representing the Context, deriving from PurchaseAnalyticsContext
:
[MetaSerializableDerived(1)]
public class GamePurchaseAnalyticsContext : PurchaseAnalyticsContext
{
[MetaMember(1)] public string Placement { get; private set; }
GamePurchaseAnalyticsContext() { }
public GamePurchaseAnalyticsContext(string placement) { Placement = placement; }
public override string GetDisplayStringForEventLog()
=> $"Placement={Placement}";
}
Next, let’s modify the purchase initiation code to include the Context. This will look a bit different for static Purchases and Dynamic-Content Purchases.
The custom Context needs to be registered when initiating the Purchase. Let’s modify the OnClickBuy
method from the example shop UI code from Getting Started with In-App Purchases:
// ShopProductScript
public void OnClickBuy()
{
// Don't try to buy the product if its purchase is already ongoing.
if (IsPurchasing())
return;
// This code used to just call IAPManager.TryBeginPurchaseProduct.
// Here, we've modified it to assign a purchase context.
// Construct the custom context information.
GamePurchaseAnalyticsContext context = new GamePurchaseAnalyticsContext(
// Include the information about the "placement" where the
// purchase was made. In this example we only deal with the shop UI,
// but if we had other UIs in the game where purchases can be made,
// we'd include that infromation here.
placement: "ShopUI");
// Invoke an action which assigns the purchase context.
MetaplayClient.PlayerContext.ExecuteAction(new PlayerPreparePurchaseContext(
productId: ProductId,
gameProductAnalyticsId: ProductId.ToString(),
gameAnalyticsContext: context
));
// Tell IAPManager to start tracking the context.
// After the server has confirmed the context, IAPManager
// will call TryBeginPurchaseProduct.
MetaplayClient.IAPManager.RegisterPendingStaticPurchase(ProductId);
}
Note also the gameProductAnalytics
field, which can optionally be used to identify the Product in the analytics event by a different identifier than productId
. In this example, we have no need to do so, so we just use the same id as productId
.
⚠️ Note
If you're using the Idler sample project as a reference, you may notice that there ShopStaticItemScript.OnClickBuy()
does not call IAPManager.RegisterPendingStaticPurchase()
directly. Instead, there the call is routed via IPlayerModelClientListenerCore.PendingStaticInAppPurchaseContextAssigned
as described in the comments in Idler's code. It is a matter of preference which way to go.
For Dynamic-Content Purchases, we need to modify the Purchase preparation action to also include the analytics Context. Taking the PrepareDynamicChestPurchase
action from the example code in the Dynamic Purchase Contents section of this document, let’s modify it to take the Context as a parameter and pass it to the SDK:
[ModelAction(ActionCodes.PrepareDynamicChestPurchase)]
public class PrepareDynamicChestPurchase : PlayerAction
{
public DynamicChestInfo ChestInfo; // Same as before
// Added this context field
public GamePurchaseAnalyticsContext AnalyticsContext;
PrepareDynamicChestPurchase(){ }
// Added the context parameter
public PrepareDynamicChestPurchase(DynamicChestInfo chestInfo, GamePurchaseAnalyticsContext analyticsContext)
{
ChestInfo = chestInfo;
AnalyticsContext = analyticsContext;
}
public override MetaActionResult Execute(PlayerModel player, bool commit)
{
... unchanged parts omitted ...
if (commit)
{
player.SetPendingDynamicInAppPurchase(
inAppProduct,
purchaseContent,
gameProductAnalyticsId: ChestInfo.ChestId.ToString(),
// Changed: passing in AnalyticsContext instead of null
gameAnalyticsContext: AnalyticsContext);
}
return MetaActionResult.Success;
}
}
Accordingly, let’s modify the OnClickBuy
method (also from the Dynamic Purchase Contents section) to pass in the Context when invoking the action:
public void OnClickBuy()
{
// Construct the custom context information.
GamePurchaseAnalyticsContext context = new GamePurchaseAnalyticsContext(
// Include the information about the "placement" where the
// purchase was made. In this example we only deal with the shop UI,
// but if we had other places in the game where purchases can be made,
// we'd include that infromation here.
placement: "ShopUI");
// Invoke the action which assigns the pending dynamic purchase content,
// and the analytics context accompanying it.
MetaplayClient.PlayerContext.ExecuteAction(
new PrepareDynamicChestPurchase(ChestInfo, context));
// Same as before:
// Tell IAPManager to start tracking the pending dynamic content.
// After the server has confirmed the pending dynamic content, IAPManager
// will call TryBeginPurchaseProduct.
InAppProductId productId = ChestInfo.InAppProduct.Ref.ProductId;
MetaplayClient.IAPManager.RegisterPendingDynamicPurchase(productId);
}
The Metaplay SDK also comes with an in-game offer feature, which uses Dynamic Purchases. If you're looking to implement offers, see Getting Started with In-Game Offers.