One Time Store Offers #

In this guide, we’ll explore how to use a custom Personalizer to implement one time store offers. This is a popular mechanic in many games, prompting players to take advantage of a special deal that is only available once, for example, a New Player Offer as in our example below.

This alters the typical default where store items can be purchased repeatedly, as long as you have enough currency.

  1. Before purchasing the item, it is visible in the store. The player has 1000 coins and 0 gems.

    Before Purchase
    Before Purchase

  2. After purchasing the item, it has been removed from the store. The player now has 900 coins and 450 gems!

    After Purchase
    After Purchase

Prerequisites #

To follow this guide you’ll need to:

Server-side #

Let’s start by taking a look at the server-side code we’ll be using to implement the one time store offers, beginning with creating our custom Personalizer in a new file called store_personalizer.go.

store_personalizer.go #

Setup #

Here we’ll add the boilerplate setup we need for a custom Personalizer and create keys that we can use when making storage operations and checking additional properties for our one time offers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package main

import (
	"context"
	"encoding/json"

	"github.com/heroiclabs/hiro"
	"github.com/heroiclabs/nakama-common/runtime"
)

const (
	StoreItemAdditionalPropertiesKeyOffer     = "offer"
	StoreItemAdditionalPropertiesValueOneTime = "one_time"

	StorageCollectionNameOffers = "economy"
	StorageKeyNameOffers        = "offers"
)

var _ hiro.Personalizer = (*StorePersonalizer)(nil)

type StorageObjectOffers struct {
	OneTime []string `json:"one_time,omitempty"`
}

type StorePersonalizer struct {
}

func NewStorePersonalizer() *StorePersonalizer {
	return &StorePersonalizer{}
}

SetItemNonPurchaseable function #

This function writes to Storage to track all purchased one_time offers as an array of store item ids.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
func (p *StorePersonalizer) SetItemNonPurchaseable(ctx context.Context, nk runtime.NakamaModule, userID, storeItemID string) error {
    // Get existing purchased one_time offers.
	readOp := &runtime.StorageRead{Collection: StorageCollectionNameOffers, Key: StoreItemAdditionalPropertiesKeyOffer, UserID: userID}
	objects, err := nk.StorageRead(ctx, []*runtime.StorageRead{readOp})
	if err != nil {
		return err
	}
	storageObjectOffers := &StorageObjectOffers{
		OneTime: make([]string, 0),
	}
    // If there are any already, deserialize into the `storageObjectOffers` object.
	if len(objects) > 0 {
		if err := json.Unmarshal([]byte(objects[0].GetValue()), storageObjectOffers); err != nil {
			return err
		}
	}

    // Append the newly purchased one_time offer and serialize, ready to write to storage.
	storageObjectOffers.OneTime = append(storageObjectOffers.OneTime, storeItemID)
	bytes, err := json.Marshal(storageObjectOffers)
	if err != nil {
		return err
	}
	writeOp := &runtime.StorageWrite{
		Collection:      StorageCollectionNameOffers,
		Key:             StorageKeyNameOffers,
		PermissionRead:  0,
		PermissionWrite: 0,
		UserID:          userID,
		Value:           string(bytes),
	}

    // Update storage with the newly appended one_time offer id.
	_, err = nk.StorageWrite(ctx, []*runtime.StorageWrite{writeOp})
	if err != nil {
		return err
	}

	return nil
}

GetValue function #

Next, we create our GetValue function. This is called whenever a value is needed from our Hiro config, such as when populating the store.

In our setup, it will first read our local JSON static definition, where all our items are enabled by default. Then in this function we can disable any items that the user has already purchased by reading from Storage, but only if they are marked as one_time items.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
func (p *StorePersonalizer) GetValue(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, system hiro.System, userID string) (any, error) {
	switch system.GetType() {
    // We only need to modify the Economy system config.
	case hiro.SystemTypeEconomy:
        // Get all purchased one_time offers for the user.
		readOp := &runtime.StorageRead{Collection: StorageCollectionNameOffers, Key: StorageKeyNameOffers, UserID: userID}
		objects, err := nk.StorageRead(ctx, []*runtime.StorageRead{readOp})
		if err != nil {
			logger.Error("StorageRead err: ", err)
			// We don't want to break the store if we cannot read one_time offers for the user.
			return nil, nil
		}
		if len(objects) < 1 {
			// User does not have any stored one_time offers purchased.
			return nil, nil
		}
		storageObjectOffers := &StorageObjectOffers{}
		if err := json.Unmarshal([]byte(objects[0].GetValue()), storageObjectOffers); err != nil {
			logger.Error("Unmarshal err: ", err)
			// We don't want to break the store if we cannot deserialize the one_time offers.
			return nil, nil
		}

        // Retreive the Economy config, ready to modify.
		config, ok := system.GetConfig().(*hiro.EconomyConfig)
		if !ok {
			logger.Error("unexpected economy system config type, using default")
			return nil, nil
		}

		// Disable any one time offers in store which have been purchased already by the user.
		for itemId, item := range config.StoreItems {
			if item.AdditionalProperties[StoreItemAdditionalPropertiesKeyOffer] != StoreItemAdditionalPropertiesValueOneTime {
                // Item is not one-time purchasable.
				continue
			}

			for _, offerId := range storageObjectOffers.OneTime {
				if itemId == offerId {
                    // User has already purchased this item.
					item.Disabled = true
					break
				}
			}
		}

		return config, nil
	default:
		return nil, nil
	}
}

main.go #

In our main.go file where Hiro is initialized, we need to make sure to add the required systems, and to hook up our custom Personalizer.

InitModule function #

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
	props, ok := ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)
	if !ok {
		return errors.New("invalid context runtime env")
	}

	env, ok := props["ENV"]
	if !ok || env == "" {
		return errors.New("'ENV' key missing or invalid in env")
	}
	logger.Info("Using env named %q", env)

	hiroLicense, ok := props["HIRO_LICENSE"]
	if !ok || hiroLicense == "" {
		return errors.New("'HIRO_LICENSE' key missing or invalid in env")
	}

	binPath := "hiro.bin"
	switch osruntime.GOOS {
	case "darwin":
		switch osruntime.GOARCH {
		case "arm64":
			binPath = "hiro-darwin-arm64.bin"
		}
	}
	logger.Info("CPU os %q arch %q detected with binPath set as %q", osruntime.GOOS, osruntime.GOARCH, binPath)

	systems, err := hiro.Init(ctx, logger, nk, initializer, binPath, hiroLicense,
		hiro.WithBaseSystem(fmt.Sprintf("base-system-%s.json", env), true),
		hiro.WithEconomySystem(fmt.Sprintf("base-economy-%s.json", env), true),
		hiro.WithInventorySystem(fmt.Sprintf("base-inventory-%s.json", env), true))
	if err != nil {
		return err
	}

	storePersonalizer := NewStorePersonalizer()
	systems.AddPersonalizer(storePersonalizer)

	// If the store item is "one_time" track that it cannot be purchased again.
	systems.GetEconomySystem().SetOnStoreItemReward(func(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, userID, sourceID string, source *hiro.EconomyConfigStoreItem, rewardConfig *hiro.EconomyConfigReward, reward *hiro.Reward) (*hiro.Reward, error) {
		offerValue, ok := source.AdditionalProperties[StoreItemAdditionalPropertiesKeyOffer]
		if ok && offerValue == StoreItemAdditionalPropertiesValueOneTime {
			if err := storePersonalizer.SetItemNonPurchaseable(ctx, nk, userID, sourceID); err != nil {
				logger.Error("error setting store item non purchaseable: %s", err)
			}
		}

		return reward, nil
	})

	return nil
}

Hiro system definitions #

Next, we create our Hiro system definitions to make use of one time offers in our game. While each registered system needs a config file, you can set up base-system-dev1.json and base-inventory-dev1.json however you like, we only need to focus on economy for this example in the base-economy-dev1.json file.

Economy #

For this example, we initialize players with some gold to be able to make purchases, as well as gems so that we can reward them when purchasing store items. Then for the store items themselves, here we show two items, one that is making use of our one_time offer additional property, and one that is a standard item that can be bought repeatedly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
{
    "initialize_user": {
        "currencies": {
            "gold": 1000,
            "gems": 0
        }
    },
    "store_items": {
        "new_player_offer": {
            "name": "New Player Offer!",
            "cost": {
                "currencies": {
                    "gold": 100
                }
            },
            "reward": {
                "guaranteed": {
                    "currencies": {
                        "gems": {
                            "min": 450
                        }
                    }
                }
            },
            "additional_properties": {
                "offer": "one_time"
            }
        },
        "standard_offer": {
            "name": "Standard Offer",
            "cost": {
                "currencies": {
                    "gold": 50
                }
            },
            "reward": {
                "guaranteed": {
                    "currencies": {
                        "gems": {
                            "min": 10
                        }
                    }
                }
            }
        }
    },
    "placements": {},
    "donations": {}
}

Client-side #

OneTimeOffersGameCoordinator #

This file bootstraps our game with a list of systems to be used, and provides a list of systems for deterministic start-up. In our case, we’re initializing the Economy and Inventory core systems from Hiro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ...
systems.Add(nakamaSystem);

// Add the Economy system
var economySystem = new EconomySystem(logger, nakamaSystem, EconomyStoreType.Unspecified);
systems.Add(economySystem);

 // Add the Inventory system
var inventorySystem = new InventorySystem(logger, nakamaSystem);
systems.Add(inventorySystem);

return Task.FromResult(systems);
// ...

StoreItemsList #

The StoreItemsList simply creates the UI for each store item, and creates an EconomySystem observer to handle UI updates when the system changes.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
public class StoreItemsList : MonoBehaviour
{
    [SerializeField] private Transform storeItemParent;
    [SerializeField] private StoreItemDisplay storeItemPrefab;
    [SerializeField] private LayoutGroup itemsLayout;

    private EconomySystem _economySystem;
    private IDisposable _economyDisposer;

    private readonly List<StoreItemDisplay> _storeItemInstances = new();

    private void Start()
    {
        // Get the Economy system and listen for updates.
        _economySystem = this.GetSystem<EconomySystem>();
        _economyDisposer = SystemObserver<EconomySystem>.Create(_economySystem, OnEconomySystemChanged);

        // Refresh the system to get the latest data.
        _economySystem.RefreshStoreAsync();
    }

    private void OnEconomySystemChanged(EconomySystem system)
    {
        if (!system.IsInitialized)
        {
            return;
        }

        // Clean up any existing UI.
        foreach (var itemInstance in _storeItemInstances)
        {
            itemInstance.OnClick -= OnStoreItemClick;
            Destroy(itemInstance.gameObject);
        }

        _storeItemInstances.Clear();

        // Create new UI for each store item.
        foreach (var item in system.StoreItems.OrderBy(x => x.Name))
        {
            var storeItemInstance = Instantiate(storeItemPrefab, storeItemParent);
            storeItemInstance.Init(item);
            storeItemInstance.OnClick += OnStoreItemClick;
            _storeItemInstances.Add(storeItemInstance);
        }

        // Make sure Unity's layout group updates correctly.
        StartCoroutine(UpdateLayoutGroup());
    }

    private IEnumerator UpdateLayoutGroup()
    {
        itemsLayout.enabled = false;
        yield return new WaitForEndOfFrame();
        itemsLayout.enabled = true;
    }

    private async void OnStoreItemClick(string itemId)
    {
        // Attempt to purchase the item.
        try
        {
            await _economySystem.PurchaseStoreItemAsync(itemId);
            await _economySystem.RefreshAsync();

            Debug.Log("Purchased!");
        }
        catch (Exception)
        {
            Debug.Log("Failed.");
        }
    }

    private void OnDestroy()
    {
        _economyDisposer?.Dispose();
    }
}

StoreItemDisplay #

The StoreItemDisplay handles updating the UI for an individual store item. It also invokes an event when clicked which is propagated up to the StoreItemsList to attempt a purchase.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
public class StoreItemDisplay : MonoBehaviour
{
    [SerializeField] private CurrencyLookup currencyLookup;
    [SerializeField] private RewardLookup storeItemLookup;
    [SerializeField] private TMP_Text nameText;
    [SerializeField] private TMP_Text rewardNameText;
    [SerializeField] private Image rewardIconImage;
    [SerializeField] private TMP_Text rewardAmountText;
    [SerializeField] private TMP_Text costText;
    [SerializeField] private Image costImage;
    [SerializeField] private GameObject limitedOfferImage;

    public event Action<string> OnClick;

    private string _itemId;

    private const string OfferKey = "offer";
    private const string OneTimeValue = "one_time";

    public void Init(IEconomyListStoreItem item)
    {
        // Store item id for attempting to purchase later.
        _itemId = item.Id;

        // Update item UI.
        nameText.text = item.Name;
        costText.text = $"{int.Parse(item.Cost.Currencies.First().Value):n0}";

        // Simple table of store item ids to display data.
        var reward = storeItemLookup.GetById(item.Id);
        rewardNameText.text = reward.DisplayName;
        rewardIconImage.sprite = reward.Sprite;

        // Simple table of currency ids to display data.
        var currency = currencyLookup.GetById(item.Cost.Currencies.First().Key);
        costImage.sprite = currency.Icon;

        // We are assuming here that the store item is going to be rewarding us with currency.
        var currencyReward = item.AvailableRewards.Guaranteed.Currencies.FirstOrDefault();
        if (!currencyReward.Equals(default))
        {
            rewardAmountText.text = currencyReward.Value.Count.Min.ToString();
        }

        // Enable an icon if the item is a one_time offer.
        if (!item.AdditionalProperties.TryGetValue(OfferKey, out var offer))
        {
            return;
        }

        if (offer == OneTimeValue)
        {
            limitedOfferImage.SetActive(true);
        }
    }

    public void Click()
    {   
        // Attempt to purchase the item.
        OnClick?.Invoke(_itemId);
    }
}