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.
Before purchasing the item, it is visible in the store. The player has 1000 coins and 0 gems.Before Purchase
After purchasing the item, it has been removed from the store. The player now has 900 coins and 450 gems!After Purchase
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.
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.
func(p*StorePersonalizer)SetItemNonPurchaseable(ctxcontext.Context,nkruntime.NakamaModule,userID,storeItemIDstring)error{// Get existing purchased one_time offers.
readOp:=&runtime.StorageRead{Collection:StorageCollectionNameOffers,Key:StoreItemAdditionalPropertiesKeyOffer,UserID:userID}objects,err:=nk.StorageRead(ctx,[]*runtime.StorageRead{readOp})iferr!=nil{returnerr}storageObjectOffers:=&StorageObjectOffers{OneTime:make([]string,0),}// If there are any already, deserialize into the `storageObjectOffers` object.
iflen(objects)>0{iferr:=json.Unmarshal([]byte(objects[0].GetValue()),storageObjectOffers);err!=nil{returnerr}}// 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)iferr!=nil{returnerr}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})iferr!=nil{returnerr}returnnil}
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.
func(p*StorePersonalizer)GetValue(ctxcontext.Context,loggerruntime.Logger,nkruntime.NakamaModule,systemhiro.System,userIDstring)(any,error){switchsystem.GetType(){// We only need to modify the Economy system config.
casehiro.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})iferr!=nil{logger.Error("StorageRead err: ",err)// We don't want to break the store if we cannot read one_time offers for the user.
returnnil,nil}iflen(objects)<1{// User does not have any stored one_time offers purchased.
returnnil,nil}storageObjectOffers:=&StorageObjectOffers{}iferr:=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.
returnnil,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")returnnil,nil}// Disable any one time offers in store which have been purchased already by the user.
foritemId,item:=rangeconfig.StoreItems{ifitem.AdditionalProperties[StoreItemAdditionalPropertiesKeyOffer]!=StoreItemAdditionalPropertiesValueOneTime{// Item is not one-time purchasable.
continue}for_,offerId:=rangestorageObjectOffers.OneTime{ifitemId==offerId{// User has already purchased this item.
item.Disabled=truebreak}}}returnconfig,nildefault:returnnil,nil}}
funcInitModule(ctxcontext.Context,loggerruntime.Logger,db*sql.DB,nkruntime.NakamaModule,initializerruntime.Initializer)error{props,ok:=ctx.Value(runtime.RUNTIME_CTX_ENV).(map[string]string)if!ok{returnerrors.New("invalid context runtime env")}env,ok:=props["ENV"]if!ok||env==""{returnerrors.New("'ENV' key missing or invalid in env")}logger.Info("Using env named %q",env)hiroLicense,ok:=props["HIRO_LICENSE"]if!ok||hiroLicense==""{returnerrors.New("'HIRO_LICENSE' key missing or invalid in env")}binPath:="hiro.bin"switchosruntime.GOOS{case"darwin":switchosruntime.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))iferr!=nil{returnerr}storePersonalizer:=NewStorePersonalizer()systems.AddPersonalizer(storePersonalizer)// If the store item is "one_time" track that it cannot be purchased again.
systems.GetEconomySystem().SetOnStoreItemReward(func(ctxcontext.Context,loggerruntime.Logger,nkruntime.NakamaModule,userID,sourceIDstring,source*hiro.EconomyConfigStoreItem,rewardConfig*hiro.EconomyConfigReward,reward*hiro.Reward)(*hiro.Reward,error){offerValue,ok:=source.AdditionalProperties[StoreItemAdditionalPropertiesKeyOffer]ifok&&offerValue==StoreItemAdditionalPropertiesValueOneTime{iferr:=storePersonalizer.SetItemNonPurchaseable(ctx,nk,userID,sourceID);err!=nil{logger.Error("error setting store item non purchaseable: %s",err)}}returnreward,nil})returnnil}
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.
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.
{"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":{}}
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 systemvareconomySystem=newEconomySystem(logger,nakamaSystem,EconomyStoreType.Unspecified);systems.Add(economySystem);// Add the Inventory systemvarinventorySystem=newInventorySystem(logger,nakamaSystem);systems.Add(inventorySystem);returnTask.FromResult(systems);// ...
publicclassStoreItemsList:MonoBehaviour{ [SerializeField]privateTransformstoreItemParent; [SerializeField]privateStoreItemDisplaystoreItemPrefab; [SerializeField]privateLayoutGroupitemsLayout;privateEconomySystem_economySystem;privateIDisposable_economyDisposer;privatereadonlyList<StoreItemDisplay>_storeItemInstances=new();privatevoidStart(){// 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();}privatevoidOnEconomySystemChanged(EconomySystemsystem){if(!system.IsInitialized){return;}// Clean up any existing UI.foreach(varitemInstancein_storeItemInstances){itemInstance.OnClick-=OnStoreItemClick;Destroy(itemInstance.gameObject);}_storeItemInstances.Clear();// Create new UI for each store item.foreach(variteminsystem.StoreItems.OrderBy(x=>x.Name)){varstoreItemInstance=Instantiate(storeItemPrefab,storeItemParent);storeItemInstance.Init(item);storeItemInstance.OnClick+=OnStoreItemClick;_storeItemInstances.Add(storeItemInstance);}// Make sure Unity's layout group updates correctly.StartCoroutine(UpdateLayoutGroup());}privateIEnumeratorUpdateLayoutGroup(){itemsLayout.enabled=false;yieldreturnnewWaitForEndOfFrame();itemsLayout.enabled=true;}privateasyncvoidOnStoreItemClick(stringitemId){// Attempt to purchase the item.try{await_economySystem.PurchaseStoreItemAsync(itemId);await_economySystem.RefreshAsync();Debug.Log("Purchased!");}catch(Exception){Debug.Log("Failed.");}}privatevoidOnDestroy(){_economyDisposer?.Dispose();}}
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.
publicclassStoreItemDisplay:MonoBehaviour{ [SerializeField]privateCurrencyLookupcurrencyLookup; [SerializeField]privateRewardLookupstoreItemLookup; [SerializeField]privateTMP_TextnameText; [SerializeField]privateTMP_TextrewardNameText; [SerializeField]privateImagerewardIconImage; [SerializeField]privateTMP_TextrewardAmountText; [SerializeField]privateTMP_TextcostText; [SerializeField]privateImagecostImage; [SerializeField]privateGameObjectlimitedOfferImage;publiceventAction<string>OnClick;privatestring_itemId;privateconststringOfferKey="offer";privateconststringOneTimeValue="one_time";publicvoidInit(IEconomyListStoreItemitem){// 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.varreward=storeItemLookup.GetById(item.Id);rewardNameText.text=reward.DisplayName;rewardIconImage.sprite=reward.Sprite;// Simple table of currency ids to display data.varcurrency=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.varcurrencyReward=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,outvaroffer)){return;}if(offer==OneTimeValue){limitedOfferImage.SetActive(true);}}publicvoidClick(){// Attempt to purchase the item.OnClick?.Invoke(_itemId);}}