In-Game Store System #

Reconstructing Fun video series
This guide is adapted from our Reconstructing Fun video series, where we explore how to build popular game mechanics using Nakama and Hiro. You can watch the full video below and follow our channel on YouTube for the latest updates.

Creating an in-game store that captivates and delights players is a key aspect of modern game design, but creating a store that resonates with players as well as overcomes the technical challenges associated with app store integrations and purchase validation can be a challenge.

In this guide, we’ll explore how to use the economy system to build your in-game store by configuring the available items, integrating them into your game’s UI, managing player purchases, and ensuring they receive their rewards seamlessly.

Prerequisites #

To follow this guide you’ll need to:

Once that’s out of the way, you can familiarize yourself with the full project code we’ll be using in this guide by cloning the In-game Store repository from GitHub.

Server-side #

Let’s start by taking a look at the server-side code we’ll be using to implement the in-game store mechanics, beginning with the main.go file.

main.go #

You can reference the full code for this file in the linked repository above. Here we’ll break down the key components of the code.

InitModule function #

Next we define our InitModule function, which is called when the server starts up. Here we’ll initialize the Hiro systems - Economy and Inventory - we’ll be using, and register the RPC functions we’ll be implementing.

 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
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")
	}

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

	binPath := "hiro.bin"
	systems, err := hiro.Init(ctx, logger, nk, initializer, binPath, hiroLicense,
		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
	}

	return nil
}

Hiro system definitions #

Next we define the Hiro system definitions we’ll be using to implement the stores in our game. These are defined in the base-inventory-dev1 file and base-economy-dev1 file respectively.

Inventory #

The Hiro Inventory system enables you to define and manage the items that can be collected and used by players in your game. In this example, we’ll use the Inventory system to define the various store item rewards that players can acquire when they make a purchase in the in-game store, setting attributes like their name, category, and maximum count.

1
2
3
4
5
6
7
8
// ...
"cosmetic_skin_biohazard": {
          "name": "Biohazard Skin",
          "category": "cosmetics",
          "item_sets": ["cosmetics_skins"],
          "max_count": 1
      }
// ...

Economy #

The Hiro Economy system enables you to define and manage the currencies that players can earn and spend in your game, and also define the currencies and amounts that each player begins the game with.

 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
{
    "initialize_user": {
        "currencies": {
            "coins": 10000,
            "gems": 1000,
            "tokens": 0
        },
        "items": {}
    },
    "store_items": {
        "cosmetic_skin_biohazard": {
          "name": "Biohazard Skin",
          "category": "cosmetics",
          "cost": {
              "currencies": {
                  "gems": 499
              }
          },
          "reward": {
              "guaranteed": {
                  "items": {
                      "cosmetic_skin_biohazard": {
                          "min": 1
                      }
                  }
              }
          }
      },
      "lucky_chest": {
          "name": "Lucky Chest",
          "category": "chests",
          "cost": {
              "currencies": {
                  "gems": 199
              }
          },
          "reward": {
              "weighted": [
                  {
                      "items": {
                          "cosmetic_skin_rockstar": {
                              "min": 1
                          }
                      },
                      "currencies": {
                          "coins": {
                              "min": 1000,
                              "max": 2000,
                              "multiple": 100
                          }
                      },
                      "weight": 1
                  },
                  {
                      "currencies": {
                          "coins": {
                              "min": 20000,
                              "max": 30000,
                              "multiple": 1000
                          }
                      },
                      "weight": 9
                  }
              ],
              "total_weight": 10,
              "max_rolls": 1
          }
      }
    }
}

Here we define the currencies and amounts that each player begins the game with. We also define the various store items available for purchase, including cosmetic skins and weighted table loot boxes (called Chests in this example).

Client-side #

InGameStoreGameCoordinator #

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 Inventory and Economy core systems from Hiro.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ...
systems.Add(nakamaSystem);
        
 // Add the Inventory system
var inventorySystem = new InventorySystem(logger, nakamaSystem);
systems.Add(inventorySystem);

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

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

InGameStoreManager #

The InGameStoreManager manages all calls to our Hiro systems, and creates system observers for each system to handle UI updates based on 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
// ...
public async Task InitAsync()
{
    // Get the Economy system and listen for updates.
    _nakamaSystem = this.GetSystem<NakamaSystem>();
    _economySystem = this.GetSystem<EconomySystem>();

    SystemObserver<EconomySystem>.Create(_economySystem, OnEconomySystemChanged);
    
    // Refresh both systems to get the latest data.
    await Task.WhenAll(_nakamaSystem.RefreshAsync(), _economySystem.RefreshAsync());
}

private void OnEconomySystemChanged(EconomySystem system)
{
    // Update coins and gems display.
    _coinsText.text = $"{system.Wallet["coins"]:n0}";
    _gemsText.text = $"{system.Wallet["gems"]:n0}";
    
    // Clear existing shop items from UI.
    foreach (Transform child in _storeGroupLarge)
    {
        Destroy(child.gameObject);
    }

    foreach (Transform child in _storeGroupSmall)
    {
        Destroy(child.gameObject);
    }
    
    // Update shop items in UI.
    var largeStoreItemCategories = new[] {"cosmetics"};
    foreach (var item in system.StoreItems.OrderBy(x => x.Name))
    {
        var isLargeStoreItem = largeStoreItemCategories.Contains(item.Category);
        var prefab = isLargeStoreItem ? _storeItemLargePrefab : _storeItemSmallPrefab;
        var parent = isLargeStoreItem ? _storeGroupLarge : _storeGroupSmall;
        var storeItem = Instantiate(prefab, parent);

        var cost = item.Cost.Currencies.First();
        storeItem.Init(item.Category, item.Id, item.Name, cost.Key, int.Parse(cost.Value));
        storeItem.OnClick += OnStoreItemClick;
    }
}

private async void OnStoreItemClick(string itemId)
{
    try
    {
        var purchaseAck = await _economySystem.PurchaseStoreItemAsync(itemId);
        await _economySystem.RefreshAsync();
        
        _rewardPanel.Show(new [] { purchaseAck.Reward });
    }
    catch (Exception)
    {
        _errorPanel.SetActive(true);
    }
}
// ...