Solo Live Event 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 engaging solo experience live events in your game offers a unique way for players to earn rewards through personal achievement. Unlike tournament based live events, these solo events focus on individual participation where the more a player engages, the more they’re rewarded.

In this guide we’ll explore the process of creating a solo live event using Nakama and Hiro where players can complete a number of stages and receive lucrative rewards for their efforts, similar to Fortnite’s battle pass 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 Solo Live Event 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.

InitModule function #

Next we define our InitModule function, which is called when the server starts up. Here we’ll initialize the Hiro systems - Achievements and Economy - 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.WithAchievementsSystem(fmt.Sprintf("base-achievements-%s.json", env), true),
	    hiro.WithEconomySystem(fmt.Sprintf("base-economy-%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 live event within our game. These are defined in the base-economy-dev1 file and base-achievements-dev1 file respectively.

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": 10000,
            "gems": 1000,
            "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.

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
 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
{
    "achievements": {
        "super_adventure_event": {
            "name": "Super Adventure!",
            "description": "Earn points by completing levels to unlock each stage of the event and earn awesome rewards!",
            "auto_claim": true,
            "auto_reset": false,
            "category": "super_adventure_event",
            "count": 0,
            "duration_sec": 432000,
            "max_count": 1,
            "precondition_ids": ["super_adventure_event_stage_5"],
            "reset_cronexpr": "0 0 * * 1",
            "reward": {},
            "sub_achievements": {
                "super_adventure_event_stage_1": {
                    "name": "Stage 1",
                    "description": "Earn 1,000 points.",
                    "auto_claim": false,
                    "auto_reset": false,
                    "category": "super_adventure_event",
                    "duration_sec": 432000,
                    "max_count": 1000,
                    "reset_cronexpr": "0 0 * * 1",
                    "reward": {
                        "guaranteed": {
                            "currencies": {
                                "coins": {
                                    "min": 100
                                }
                            }
                        }
                    }
                },
                "super_adventure_event_stage_2": {
                    "name": "Stage 2",
                    "description": "Earn 5,000 points.",
                    "auto_claim": false,
                    "auto_reset": false,
                    "category": "super_adventure_event",
                    "duration_sec": 432000,
                    "max_count": 5000,
                    "reset_cronexpr": "0 0 * * 1",
                    "precondition_ids": ["super_adventure_event_stage_1"],
                    "reward": {
                        "guaranteed": {
                            "currencies": {
                                "gems": {
                                    "min": 10
                                }
                            }
                        }
                    }
                },
                "super_adventure_event_stage_3": {
                    "name": "Stage 3",
                    "description": "Earn 10,000 points.",
                    "auto_claim": false,
                    "auto_reset": false,
                    "category": "super_adventure_event",
                    "duration_sec": 432000,
                    "max_count": 10000,
                    "reset_cronexpr": "0 0 * * 1",
                    "precondition_ids": ["super_adventure_event_stage_2"],
                    "reward": {
                        "guaranteed": {
                            "currencies": {
                                "coins": {
                                    "min": 10000
                                }
                            }
                        }
                    }
                },
                "super_adventure_event_stage_4": {
                    "name": "Stage 4",
                    "description": "Earn 50,000 points.",
                    "auto_claim": false,
                    "auto_reset": false,
                    "category": "super_adventure_event",
                    "duration_sec": 432000,
                    "max_count": 50000,
                    "reset_cronexpr": "0 0 * * 1",
                    "precondition_ids": ["super_adventure_event_stage_3"],
                    "reward": {
                        "guaranteed": {
                            "currencies": {
                                "gems": {
                                    "min": 100
                                }
                            }
                        }
                    }
                },
                "super_adventure_event_stage_5": {
                    "name": "Stage 5",
                    "description": "Earn 100,000 points.",
                    "auto_claim": false,
                    "auto_reset": false,
                    "category": "super_adventure_event",
                    "duration_sec": 432000,
                    "max_count": 100000,
                    "reset_cronexpr": "0 0 * * 1",
                    "precondition_ids": ["super_adventure_event_stage_4"],
                    "reward": {
                        "guaranteed": {
                            "currencies": {
                                "coins": {
                                    "min": 100000
                                }
                            }
                        }
                    }
                }
            }
        }
        // ...
    }
}

For our game, we have created a solo live event achievement that consists of several sub-achievements. These sub-achievements map to the various progression stages within our live event, each of which will reward the player upon completion.

Client-side #

SoloLiveEventGameCoordinator #

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 Achievements 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 Achievements system
var achievementsSystem = new AchievementsSystem(logger, nakamaSystem);
systems.Add(achievementsSystem);

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

SoloLiveEventManager #

The SoloLiveEventManager 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
// ...
public async Task InitAsync()
{
    // Get a reference to the Hiro systems.
    _achievementsSystem = this.GetSystem<AchievementsSystem>();
    _economySystem = this.GetSystem<EconomySystem>();

    // Create observers to update the UI when changes occur.
    SystemObserver<AchievementsSystem>.Create(_achievementsSystem, OnAchievementSystemChanged);
    SystemObserver<EconomySystem>.Create(_economySystem, OnEconomySystemChanged);

    // Grab the latest data from both systems.
    await Task.WhenAll(_achievementsSystem.RefreshAsync(), _economySystem.RefreshAsync());
}

private void OnEconomySystemChanged(EconomySystem system)
{
    // Update the coins and gems display.
    _coinsText.text = $"{system.Wallet["coins"]:n0}";
    _gemsText.text = $"{system.Wallet["gems"]:n0}";
}

private void OnAchievementSystemChanged(AchievementsSystem system)
{
    // Update the UI to show/hide the event popup and set the event achievement private field.
    var availableAchievements = system.GetAvailableRepeatAchievements("super_adventure_event").ToList();
    if (availableAchievements.Any())
    {
        // Cache the event variable.
        _eventAchievement = availableAchievements.First();
        
        // Show the event popup in the UI.
        _soloLiveEventPopup.gameObject.SetActive(true);
        _soloLiveEventPopup.Init(_eventAchievement.Name, _eventAchievement.ResetTimeSec);

        // Clear the event stages container.
        foreach (Transform child in _soloLiveEventStagesContainer)
        {
            Destroy(child.gameObject);
        }
        

        // Keep track of how many stages there are and how many are complete.
        var totalSubAchievements = _eventAchievement.SubAchievements.Count;
        var totalCompletedSubAchievements = 0;
        
        // Populate event stages container.
        foreach (var subAchievement in _eventAchievement.SubAchievements)
        {
            // Add the event stage element to the UI and bind to the StageClaimClicked event.
            var stage = Instantiate(_soloLiveEventStagePrefab, _soloLiveEventStagesContainer);
            stage.Init(subAchievement.Value);
            stage.StageClaimClicked += OnStageClaimClicked;

            // If the sub achievement is complete, the stage is complete.
            if (subAchievement.Value.ClaimTimeSec > 0)
            {
                totalCompletedSubAchievements++;
            }
        }
        
        // Update the event stages progression slider to show what stage the player has reached.
        _stageSlider.value = (float) totalCompletedSubAchievements / totalSubAchievements;
        _stageSlider.handleRect.GetComponentInChildren<TMP_Text>().text = totalCompletedSubAchievements.ToString();
    }
    else
    {
        // Clear the cached event variable and hide the event popup UI element.
        _eventAchievement = null;
        _soloLiveEventPopup.gameObject.SetActive(false);
    }
}

private async void OnStageClaimClicked(string achievementId, string rewardText, Sprite rewardSprite)
{
    // Claim the specified achievement.
    await _achievementsSystem.ClaimAchievementsAsync(new[] {achievementId});

    // Update and show the reward popup.
    _rewardPopupText.text = rewardText;
    _rewardPopupImage.sprite = rewardSprite;
    _rewardPopup.SetActive(true);
}
// ...