# One Time Store Offers

**URL:** https://heroiclabs.com/docs/hiro/guides/personalizer/one-time-store-offers/
**Keywords:** one time store offers, hiro
**Categories:** hiro, one-time-store-offers, personalizer

---


# One Time Store Offers

In this guide, we'll explore how to use a custom [Personalizer](../../../concepts/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]({{< fingerprint_image "/images/pages/hiro/guides/one-time-purchase/before-purchase.jpg" >}})

2. After purchasing the item, it has been removed from the store. The player now has 900 coins and 450 gems!
   ![After Purchase]({{< fingerprint_image "/images/pages/hiro/guides/one-time-purchase/after-purchase.jpg" >}})

## Prerequisites

To follow this guide you'll need to:

- [Install Nakama](../../../../nakama/getting-started/install/docker/)
- [Install Hiro](../../../../hiro/concepts/getting-started/install/)
- [Install Unity](https://unity3d.com/get-unity/download)

## 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](../../../concepts/personalizer) in a new file called `store_personalizer.go`.

### `store_personalizer.go`

#### Setup

Here we'll add the setup code that is needed for our custom [Personalizer](../../../concepts/personalizer) and create keys that we can use when making storage operations and checking additional properties for our one time offers.

```go
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](../../../../nakama/getting-started/console/#storage) to track all purchased `one_time` offers as an array of store item ids.

```go
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](../../../../nakama/getting-started/console/#storage/), but only if they are marked as `one_time` items.

```go
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](../../../concepts/personalizer).

#### `InitModule` function

```go
// ...
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
})
// ...
```

### 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.

```json
{
  "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.

```csharp
// ...
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.

```csharp
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 IEnumerator Start()
    {
        // Get the Economy system and listen for updates.
        _economySystem = this.GetSystem<EconomySystem>();
        _economyDisposer = SystemObserver<EconomySystem>.Create(_economySystem, OnEconomySystemChanged);

        yield return new WaitUntil(() => _economySystem.IsInitialized);

        // 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.

```csharp
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);
    }
}
```
