Randomize daily missions

Daily missions keep players engaged when they feel fresh and fair. Everyone faces the same challenge, and that challenge changes predictably every day. This guide shows you how to use a Hiro personalizer to rotate a shared set of missions for all players. Each day, a deterministic seed selects a fixed number of missions from a larger pool, so the set refreshes automatically at midnight UTC and every player sees the same missions.

The approach has two parts: defining all possible missions in your JSON config, and writing a personalizer that filters which ones are visible on a given day. The config is the source of truth for mission definitions; the personalizer is what makes the rotation happen.

Prerequisites #

Before you start, make sure you have:

Installed:

Familiarized yourself with:

  • Achievements: The mission pool is modeled as a parent achievement with sub-achievements as child goals.
  • Personalizers: A personalizer has a customizable GetValue method that Hiro calls before returning any system config to a client. You’ll implement one to intercept the full mission pool and return only today’s subset.
  • Server-side modules: You’ll register the personalizer in your InitModule function.

Configure achievements #

Your JSON config defines the full pool of missions: every mission that could ever appear in rotation. The personalizer doesn’t control what missions exist; it only controls which ones players see on a given day.

Create the parent achievement #

Start by adding a parent achievement with the ID daily_missions. This ID is the anchor point your Go code uses to locate the pool at runtime.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "achievements": {
    "daily_missions": {
      "category": "Daily Challenges",
      "count": 0,
      "max_count": 1,
      "name": "Daily Challenges",
      "reward": {},
      "sub_achievements": {
        "mission_01": { ... },
        "mission_02": { ... },
        "mission_03": { ... },
        "mission_04": { ... }
      }
    }
  }
}

max_count: 1 marks the parent as a one-time container. It’s not a mission itself, just a holder for the pool. The actual missions are the sub-achievements nested inside it.

Define the mission pool #

Each sub-achievement is an individual mission. Two fields on each one control daily behavior:

  • reset_cronexpr: "0 0 * * *" resets progress at midnight UTC each day.
  • duration_sec: 86400 gives players a 24-hour window to complete the mission.

Here’s the full pool with four example missions:

base-achievements.json
 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
{
  "achievements": {
    "daily_missions": {
      "category": "Daily Challenges",
      "count": 0,
      "max_count": 1,
      "name": "Daily Challenges",
      "reward": {},
      "sub_achievements": {
        "mission_01": {
          "auto_claim": true,
          "category": "Daily Challenges",
          "count": 0,
          "description": "Win 3 Matches",
          "reset_cronexpr": "0 0 * * *",
          "duration_sec": 86400,
          "max_count": 3,
          "name": "Match Winner",
          "reward": {
            "guaranteed": {
              "currencies": {
                "gold": { "min": 50 }
              }
            }
          },
          "additional_properties": { "type": "pvp" }
        },
        "mission_02": {
          "auto_claim": true,
          "category": "Daily Challenges",
          "count": 0,
          "description": "Spend 100 Gold",
          "reset_cronexpr": "0 0 * * *",
          "duration_sec": 86400,
          "max_count": 100,
          "name": "Big Spender",
          "reward": {
            "guaranteed": {
              "items": {
                "experience_potion": { "min": 1, "max": 1 }
              }
            }
          },
          "additional_properties": { "type": "economy" }
        },
        "mission_03": {
          "auto_claim": true,
          "category": "Daily Challenges",
          "count": 0,
          "description": "Heal 500 Damage",
          "reset_cronexpr": "0 0 * * *",
          "duration_sec": 86400,
          "max_count": 500,
          "name": "Combat Medic",
          "reward": {
            "guaranteed": {
              "currencies": {
                "gold": { "min": 75 }
              }
            }
          },
          "additional_properties": { "type": "support" }
        },
        "mission_04": {
          "auto_claim": true,
          "category": "Daily Challenges",
          "count": 0,
          "description": "Travel 1km",
          "reset_cronexpr": "0 0 * * *",
          "duration_sec": 86400,
          "max_count": 1000,
          "name": "World Traveler",
          "reward": {
            "guaranteed": {
              "currencies": {
                "gold": { "min": 25 }
              }
            }
          },
          "additional_properties": { "type": "exploration" }
        }
      }
    }
  }
}

additional_properties lets your client filter or display missions by type without adding custom fields to the schema.

Tip
Keep at least N + 1 sub-achievements if you plan to show N per day. This prevents duplicates and keeps rotation interesting.

Once Hiro loads this config, all four missions exist in the system. No player can see them yet. That’s what the personalizer in the next section controls.

Implement the personalizer #

A personalizer intercepts every config request before Hiro returns it to a client. You implement the GetValue method, which receives the current system config and returns a modified version, or nil to leave it unchanged.

This personalizer does two things: it finds the daily_missions pool, and it replaces the full pool with a fixed-size subset chosen by a seed derived from the current UTC day.

GetValue #

GetValue is called for every Hiro system: achievements, economy, inventory, and so on. The method checks it’s dealing with the Achievements system, looks up the mission pool, generates today’s seed, and returns the filtered config:

main.go
 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
package main

import (
    "context"
    "math/rand"
    "slices"
    "time"

    "github.com/heroiclabs/hiro"
    "github.com/heroiclabs/nakama-common/runtime"
)

const (
    dailyMissionsAchievementID = "daily_missions"
    dailyMissionsCountPerDay   = 3 // Adjust to the number of missions you want visible
)

type achievementsPersonalizer struct{}

func (a *achievementsPersonalizer) GetValue(
    ctx context.Context,
    logger runtime.Logger,
    nk runtime.NakamaModule,
    system hiro.System,
    identity string,
) (any, error) {
    if system.GetType() != hiro.SystemTypeAchievements {
        return nil, nil
    }

    achievementsConfig, ok := system.GetConfig().(*hiro.AchievementsConfig)
    if !ok {
        return nil, nil
    }

    dailyMissions, ok := achievementsConfig.Achievements[dailyMissionsAchievementID]
    if !ok {
        return nil, nil
    }

    currentTime := time.Now().UTC()
    startOfDay := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), 0, 0, 0, 0, time.UTC)
    dailySeed := startOfDay.Unix()

    dailyMissions.SubAchievements = a.selectRandomSubAchievements(
        dailyMissions.SubAchievements,
        dailySeed,
        dailyMissionsCountPerDay,
    )

    return achievementsConfig, nil
}

A few things worth understanding here:

  • system.GetConfig() returns the live config as a generic value. The type assertion to *hiro.AchievementsConfig confirms you have what you need. Returning nil, nil from any guard tells Hiro to use the original config unchanged.
  • dailySeed is the Unix timestamp of midnight UTC for today. Using UTC is important: if you used local time, players in different timezones would see different missions, or the selection could shift mid-day if the server runs in a different region. Because the seed is identical for every player on a given day, any client that calls GetAchievements sees the same set of missions regardless of when they log in.
  • You’re modifying the config in memory and returning it. Hiro uses your version for this request without persisting the change. The underlying JSON config is untouched.

Select missions without duplicates #

The selectRandomSubAchievements helper picks limit missions from the pool using a deterministic shuffle: the same seed always produces the same selection.

main.go
 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
func (a *achievementsPersonalizer) selectRandomSubAchievements(
    subAchievements map[string]*hiro.AchievementsConfigSubAchievement,
    dailySeed int64,
    limit int,
) map[string]*hiro.AchievementsConfigSubAchievement {
    subAchievementIDs := make([]string, 0, len(subAchievements))
    for id := range subAchievements {
        subAchievementIDs = append(subAchievementIDs, id)
    }
    slices.Sort(subAchievementIDs)

    result := make(map[string]*hiro.AchievementsConfigSubAchievement, limit)
    random := rand.New(rand.NewSource(dailySeed))

    for len(result) < limit && len(subAchievementIDs) > 0 {
        randomIndex := random.Intn(len(subAchievementIDs))
        chosenID := subAchievementIDs[randomIndex]
        result[chosenID] = subAchievements[chosenID]

        subAchievementIDs[randomIndex] = subAchievementIDs[len(subAchievementIDs)-1]
        subAchievementIDs = subAchievementIDs[:len(subAchievementIDs)-1]
    }

    return result
}

The sort step is critical. Go map iteration order is random, so without sorting the keys first, the same seed could produce different results on different runs. Sorting ensures all callers start from an identical, ordered list before the random selection begins.

The selection itself goes like this: choose a random index, add that mission to the result, then swap it to the end of the remaining slice and shrink the slice. This avoids duplicates without needing to check the result set on each iteration.

Use UTC format
Using UTC ensures the same seed globally and avoids per-region drift.

Register the personalizer in InitModule #

Wire the personalizer into Hiro by passing it via hiro.WithPersonalizer when the server starts:

main.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func InitModule(ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule, initializer runtime.Initializer) error {
    _, err := hiro.Init(
        ctx, logger, nk, initializer,
        binfs.AssetNames(),
        binfs.Asset,
        hiro.WithPersonalizer(&achievementsPersonalizer{}),
    )
    if err != nil {
        return err
    }
    return nil
}

Once the server restarts, Hiro calls GetValue on every config request. For non-Achievements systems, your personalizer immediately returns nil and Hiro moves on. For Achievements requests, it applies the daily filter before anything reaches the client.

Verify mission rotation #

  1. Start the server. Build and run your Nakama module so Hiro loads the updated config and personalizer.

  2. Fetch achievements. From a client or test harness, request achievements and confirm only dailyMissionsCountPerDay sub-achievements appear under daily_missions.

  3. Check determinism. Repeat the request from a second client. Both clients should see the same set of missions.

  4. Advance the day. Move the system clock past 00:00:00 UTC and request achievements again. You should see a different subset drawn from the same pool.

Troubleshooting #

  • Missions don’t change at midnight. Confirm startOfDay is computed with time.UTC and that client countdowns target 00:00 UTC, not local midnight.

  • Empty or too few missions. Ensure the pool size in sub_achievements exceeds dailyMissionsCountPerDay.

  • Progress isn’t tracked. Keep sub-achievement IDs stable across deployments. Reusing an ID lets Nakama attribute progress to the correct player record.

Variations #

Per-player daily missions #

To give each player a unique set of missions, replace the seed calculation in GetValue with one that also incorporates the player’s identity:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
currentTime := time.Now().UTC()
startOfDay := time.Date(currentTime.Year(), currentTime.Month(), currentTime.Day(), 0, 0, 0, 0, time.UTC)

hasher := fnv.New64a()
hasher.Write([]byte(identity))
dailySeed := startOfDay.Unix() + int64(hasher.Sum64())

dailyMissions.SubAchievements = a.selectRandomSubAchievements(
    dailyMissions.SubAchievements,
    dailySeed,
    dailyMissionsCountPerDay,
)

This keeps missions consistent for a given player throughout the day, while producing a different set for each player. Add "hash/fnv" to your imports.

Additional information #