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

In the vibrant landscape of gaming, achievements serve as milestones marking the player’s journey, offering both satisfaction and tangible rewards. They’re the unsung heroes of player retention, providing goals that keep the experience fresh and engaging.

In this guide we’ll explore how you can quickly integrate quests and achievements using Nakama and Hiro to produce gameplay experiences similar to that of Blizzard’s massively successful Hearthstone quests system.

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 Quests repository from GitHub.

Server-side #

Let’s start by taking a look at the server-side code we’ll be using to implement the quest 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.

Define error messages #

First we define the error messages we’ll use throughout the codebase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
var (
	errBadInput        = runtime.NewError("input contained invalid data", 3) // INVALID_ARGUMENT
	errInternal        = runtime.NewError("internal server error", 13)       // INTERNAL
	errMarshal         = runtime.NewError("cannot marshal type", 13)         // INTERNAL
	errNoInputAllowed  = runtime.NewError("no input allowed", 3)             // INVALID_ARGUMENT
	errNoInputGiven    = runtime.NewError("no input was given", 3)           // INVALID_ARGUMENT
	errNoUserIdFound   = runtime.NewError("no user ID in context", 3)        // INVALID_ARGUMENT
	errNoUsernameFound = runtime.NewError("no username in context", 3)       // INVALID_ARGUMENT
	errUnmarshal       = runtime.NewError("cannot unmarshal type", 13)       // INTERNAL
)

InitModule function #

Next we define our InitModule function, which is called when the server starts up. Here we’ll initialize the Hiro systems - Achievements, 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
27
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.WithAchievementsSystem(fmt.Sprintf("base-achievements-%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
	}

	return nil
}

Hiro system definitions #

Next we define the Hiro system definitions we’ll be using to implement the various quests within our game. These are defined in the base-inventory-dev1 file, base-economy-dev1 file, and base-achievements-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 rewards that players can acquire when they complete achievements, setting attributes like their name, description, maximum count, stackability, and rarity.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// ...
"special_card_back": {
            "name": "Special Card Back",
            "description": "",
            "category": "card_backs",
            "item_sets": [
                "card_backs"
            ],
            "max_count": 999,
            "stackable": true,
            "consumable": false,
            "consume_reward": null,
            "string_properties": {},
            "numeric_properties": {}
        }
// ...

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
{
    "initialize_user": {
        "currencies": {
            "coins": 0,
            "gems": 0,
            "tokens": 0
        },
        "items": {}
    },
    "store_items": {}
}

Here we define the currencies and amounts that each player begins the game with, as well as the items that each player begins the game with. In this example, we’ve started the player with a blank slate so that we can more easily demonstrate the quest rewards being given to the player.

Achievements #

The Hiro Achievements system allows you to define and manage the various achievements within your game. These can be one-off or recurring achievements that players can complete to earn various rewards or prestige. Achievements can also have specific pre-conditions that a player must meet before they can contribute towards their completion. They can also have nested sub achievements should your gameplay design require this.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
{
  "achievements": {
      "first_victory": {
          "name": "First Victory",
          "description": "Win your first game.",
          "category": "once",
          "count": 0,
          "max_count": 1,
          "reward": {
              "guaranteed": {
                  "currencies": {
                      "coins": {
                          "min": 1000
                      }
                  }
              }
          },
          "additional_properties":  {
              "icon_name": "wins"
          }
      }
  },
  // ...
}

For our game, we have created several one-off achievements as well as both daily and weekly recurring achievements that all reward the player with some form of currency or in-game item.

Client-side #

QuestsGameCoordinator #

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ...
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);

// Add the Achievements system
var achievementsSystem = new AchievementsSystem(logger, nakamaSystem);
systems.Add(achievementsSystem);

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

QuestsManager #

The QuestsManager 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
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
// ...
public async Task InitAsync()
{
    // Get the Economy system and listen for updates.
    _economySystem = this.GetSystem<EconomySystem>();
    SystemObserver<EconomySystem>.Create(_economySystem, OnEconomySystemUpdated);
    
    // Get the Achievements system and listen for updates.
    _achievementsSystem = this.GetSystem<AchievementsSystem>();
    SystemObserver<AchievementsSystem>.Create(_achievementsSystem, OnAchievementsSystemUpdated);

    // Refresh both systems to get the latest data.
    await Task.WhenAll(_economySystem.RefreshAsync(), _achievementsSystem.RefreshAsync());
}

private void OnEconomySystemUpdated(EconomySystem economySystem)
{
    coinsCounter.text = _economySystem.Wallet["coins"].ToString("N0"); 
    gemsCounter.text = _economySystem.Wallet["gems"].ToString("N0");
}

private void OnAchievementsSystemUpdated(AchievementsSystem achievementsSystem)
{
    // Populate the once achievements
    UpdateQuestGrid(onceGrid, _achievementsSystem.GetAvailableAchievements("once"), false);

    // Populate the daily achievements
    UpdateQuestGrid(dailyGrid, _achievementsSystem.GetAvailableRepeatAchievements("daily"), true);
    
    // Populate the weekly achievements
    UpdateQuestGrid(weeklyGrid, _achievementsSystem.GetAvailableRepeatAchievements("weekly"), true);
}

private void UpdateQuestGrid(Transform grid, IEnumerable<IAchievement> achievements, bool repeating)
{
    // Clear existing items from the UI.
    foreach (Transform child in grid)
    {
        Destroy(child.gameObject);
    }

    // Iterate through each achievement and update the UI.
    foreach (var achievement in achievements)
    {
        // Determine if the Achievement has been claimed.
        var claimed = achievement.ClaimTimeSec > 0;
        
        // Get the icon name from the Achievement's AdditionalProperties metadata if possible.
        var iconName = "count";
        if (achievement.AdditionalProperties.TryGetValue("icon_name", out var n))
        {
            iconName = n;
        }
        
        // Instantiate a Quest Item UI element and initialize it with the appropriate display data.
        var questItem = Instantiate(questItemPrefab, grid);
        questItem.Init(achievement.Description, achievement.Count, achievement.MaxCount, iconName, repeating, claimed);
        
        // Listen for click events and claim the Achievement when it is clicked.
        questItem.ClaimClicked += async () =>
        {
            // Claim the Achievement.
            var achievementsUpdateAck = await _achievementsSystem.ClaimAchievementsAsync(new[] {achievement.Id});
            
            // Refresh the Economy system so that we can update the UI appropriately after claiming.
            await _economySystem.RefreshAsync();
            
            // Show the Reward popup panel.
            ShowClaimRewardPanel(achievementsUpdateAck);
        };
    }
}

private void ShowClaimRewardPanel(IAchievementsUpdateAck ack)
{
    // Inline function to update the Reward popup panel UI for an Achievement's rewards.
    void AddAchievementRewardToRewardPanel(IAchievement achievement)
    {
        // Iterate through the Item rewards and add them to the UI.
        foreach (var item in achievement.Reward.Items)
        {
            var rewardItem = Instantiate(rewardItemPrefab, rewardGrid);
            rewardItem.GetComponent<QuestsRewardItemUI>().Init(item.Key, item.Value);
        }
        
        // Iterate through the Energy Modifier rewards and add them to the UI.
        foreach (var energyModifier in achievement.Reward.EnergyModifiers)
        {
            var rewardItem = Instantiate(rewardItemPrefab, rewardGrid);
            var text = energyModifier.Operator == "infinite" ? "∞" : energyModifier.Value.ToString();
    
            var timespan = TimeSpan.FromSeconds(energyModifier.DurationSec);
            text += $" ({timespan.TotalMinutes}m) ";
            
            rewardItem.GetComponent<QuestsRewardItemUI>().Init(energyModifier.Id, text);
        }
        
        // Iterate through the Currency rewards and add them to the UI.
        foreach (var currency in achievement.Reward.Currencies)
        {
            var rewardItem = Instantiate(rewardItemPrefab, rewardGrid);
            rewardItem.GetComponent<QuestsRewardItemUI>().Init(currency.Key, currency.Value);
        }
    }
    
    // Clear existing rewards from the reward panel.
    foreach (Transform child in rewardGrid)
    {
        Destroy(child.gameObject);
    }
    
    // Add rewards from normal achievements.
    foreach (var achievement in ack.Achievements)
    {
        AddAchievementRewardToRewardPanel(achievement.Value);
    }
    
    // Add rewards from repeat achievements.
    foreach (var achievement in ack.RepeatAchievements)
    {
        AddAchievementRewardToRewardPanel(achievement.Value);
    }

    // Show the reward popup.
    rewardPanel.gameObject.SetActive(true);
}
// ...