Creating an Economy

This guide will demonstrate how you can develop in-game economy systems using MetaFi’s IAP validation, virtual wallet and storage engine functionality.

For this guide we will allow players to purchase a premium currency, Gems, via IAPs. These Gems can then be spent in-game to purchase Coins which can in turn be used to purchase in-game items.

We will also allow players to become a Premium player using an IAP and later restore that purchase if necessary.

Purchasing premium currency with IAPs

The following server runtime code example assumes the use of the Unity IAP package to submit a purchase receipt to a custom MetaFi RPC.

This RPC first checks the payload to see which app store was used to make the purchase, then validates the purchase with the appropriate app store. If the purchase is valid, it calls a separate function for each validated purchase in the array to check the purchase’s product ID and give the appropriate amount of Gems to the player in their virtual wallet.

Note that for MetaFi to validate purchases you must provide configuration variables appropriate to each app store

// Server
let RpcValidateIAP: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime, payload: string): string {
  // Assumes payload is a Unity IAP receipt
  const iap = JSON.parse(payload);

  // Validate the purchases depending on which app store was used
  let validatePurchaseResponse : nkruntime.ValidatePurchaseResponse;
  switch (iap.store) {
    case 'GooglePlay':
      validatePurchaseResponse = nk.purchaseValidateGoogle(ctx.userId, iap.payload);
      break;
    case 'AppleAppStore':
      validatePurchaseResponse = nk.purchaseValidateApple(ctx.userId, iap.payload);
      break;
    default:
      logger.warn('Unrecognised app store in payload')
      return JSON.stringify({ success: false });
      break;
  }

  validatePurchaseResponse.validatedPurchases.forEach(p => rewardPurchase(ctx, logger, nk, p));
  return JSON.stringify({ success: true });
};

let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime, validatedPurchase: nkruntime.ValidatedPurchase): void {
  // Here we are just dealing with consumable IAPs
  switch (validatedPurchase.productId) {
    case 'gems_100':
      nk.walletUpdate(ctx.userId, { gems: 100 }, null, true);
      break;
    case 'gems_1000':
      nk.walletUpdate(ctx.userId, { gems: 1000 }, null, true);
      break;
  }
};

Purchasing in-game currency with premium currency

Now that the player has purchased a premium currency, they can use it to purchase an in-game currency, Coins. The following RPC allows the user to specify how many Gems they would like to spend on Coins. Here the conversion rate (1 Gem = 1000 Coins) is hardcoded.

// Server
let RpcPurchaseCoins: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime, payload: string): string {
  const request = JSON.parse(payload);

  if (!request.gemsToSpend) {
    logger.warn('No gemsToSpend specified in RpcPurchaseCoins payload.');
    return JSON.stringify({ success: false, error: 'Failed to provide gems to spend amount.' });
  }

  // Check that the user has enough gems to spend
  const account = nk.accountGetId(ctx.userId);
  if (account.wallet['gems'] < request.gemsToSpend) {
    logger.warn('User does not have enough gems.');
    return JSON.stringify({ success: false, error: 'Not enough gems.' });
  }

  // Spend
  const coinsPerGem = 1000;
  nk.walletUpdate(ctx.userId, { coins: coinsPerGem * request.gemsToSpend, gems: -request.gemsToSpend });

  return JSON.stringify({ success: true });
};

Purchasing items with in-game currency

For this example we’re going to store a configuration object inside the Storage Engine which will map each item in the game to a price in Coins. We will configure this inside our server’s InitModule function.

// Server
const itemPrices = {
    'iron-sword': 100,
    'iron-shield': 150,
    'steel-sword': 500
  };

  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000', // Owned by the system user
    permissionRead: 2, // Public read
    permissionWrite: 0, // No write
    value: itemPrices
  };

  nk.storageWrite([ writeRequest ]);

With our prices stored in the Storage Engine we can write an RPC that will allow the user to buy an item (provided they have enough Coins).

// Server
let RpcPurchaseItem: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime, payload: string): string {
  const request = JSON.parse(payload);

  // Make sure the user specified an item to buy
  if (!request.itemName) {
    logger.warn('No item name specified.');
    return JSON.stringify({ success: false, error: 'No item name specified.'});
  }

  // Lookup the item prices
  const readRequest: nkruntime.StorageReadRequest = {
    collection: 'configuration',
    key: 'prices',
    userId: '00000000-0000-0000-0000-000000000000'
  };

  const readResult = nk.storageRead([readRequest]);
  if (readResult.length == 0)
  {
    logger.warn('No item prices in storage.');
    return JSON.stringify({ success: false, error: 'No item prices available.' });
  }
  
  const prices = readResult[0].value;

  // Check if there is a price for the requested item
  if (!prices[request.itemName]) {
    logger.warn(`No price available for ${request.itemName}`);
    return JSON.stringify({ success: false, error: `No price available for ${request.itemName}` });
  }

  // Check that the player has enough coins
  const account = nk.accountGetId(ctx.userId);

  if (account.wallet['coins'] < prices[request.itemName]) {
    logger.warn('Not enough coins to purchase item.');
    return JSON.stringify({ success: false, error: 'Not enough coins to purchase item.' });
  }

  // Decrease the player's coins
  nk.walletUpdate(ctx.userId, { coins: -prices[request.itemName] });

  // Get the player's current inventory.
  let inventory = {};

  const inventoryReadRequest: nkruntime.StorageReadRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId
  };

  const result = nk.storageRead([inventoryReadRequest]);
  if (result.length > 0) {
    inventory = result[0].value;
  }

  // Give the player the item (either increase quantity if they already possessed it or add one)
  if (inventory[request.itemName]) {
    inventory[request.itemName] += 1;
  } else {
    inventory[request.itemName] = 1;
  }

  // Define the storage write request to update the player's inventory.
  const writeRequest: nkruntime.StorageWriteRequest = {
    collection: 'economy',
    key: 'inventory',
    userId: ctx.userId,
    permissionWrite: 1,
    permissionRead: 1,
    value: inventory
  };

  // Write the updated inventory to storage.
  const storageWriteAck = nk.storageWrite([writeRequest]);

  // Return an error if the write does not succeed.
  if (!storageWriteAck || storageWriteAck.length == 0) {
    return JSON.stringify({ success: false, error: 'Error saving inventory.' });
  }

  return JSON.stringify({ success: true });
};

Purchasing non-consumables with IAPs

As well as being able to purchase a virtual currency through In-App Purchases, you may wish to provide players with the ability to directly purchase non-consumable goods too.

For this example, we’ll revisit our server runtime RPC from earlier that allowed players to purchase Gems. However, we’ll now add the ability to purchase a non-consumable which can be restored on a different device later. The non-consumable in this instance will be the ability to become a Premium status player.

For this, once the purchase has been validated, we will set a flag in the user’s metadata to indicate that they are a Premium player. This can then be used throughout the game to provide various perks/rewards.

// Server
let rewardPurchase = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime, validatedPurchase: nkruntime.ValidatedPurchase): void {
  switch (validatedPurchase.productId) {
    // ...existing cases omitted
    case 'premium_status':
      const account = nk.accountGetId(ctx.userId);
      const metadata = account.user.metadata;
      metadata['premium'] = true;
      nk.accountUpdateId(ctx.userId, null, null, null, null, null, null, metadata);
      break;
  }
};

Restoring an IAP purchase

If a user changes their device, they should be able to restore any purchases they had previously made. This does not apply to consumable purchases (e.g. virtual currency) which will have already been “consumed” at the time of purchase, but for things such as unlocking the full game, removing ads, or becoming a Premium member, the user should receive all of the same benefits on any new device where they install your game.

For this, we will provide an RPC that the game client can call to receive a list of all the IAP Product IDs and purchase timestamps that have been verified as successful purchases in MetaFi. The client can then use this information to restore the appropriate feature on the new device.

// Server
let RpcRestorePurchases: nkruntime.RpcFunction = function (ctx: nkruntime.Context, logger: nkruntime.Logger, nk: nkruntime, payload: string): string {
  const purchases = nk.purchasesList(ctx.userId);

  const response = {
    purchases = purchases.validatedPurchases.map(v => { v.productId, v.purchaseTime })
  };

  return JSON.stringify(response);
};

Last updated