# Randomize daily missions

**URL:** https://heroiclabs.com/docs/hiro/guides/personalizer/random-daily-missions/

---


# 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](../../../concepts/personalizers/) 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:**

* [Nakama](../../../../nakama/getting-started/install/docker/)
* [Hiro](../../../../hiro/concepts/getting-started/install/)

**Familiarized yourself with:**

* [Achievements](../../../concepts/achievements/): The mission pool is modeled as a parent achievement with [sub-achievements](../../../concepts/achievements/sub-achievements/) as child goals.
* [Personalizers](../../../concepts/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](../../../server-framework/achievements/): 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.

```json
{
  "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:

{{< code type="server" filename="base-achievements.json" hideable="false" >}}

```json
{
  "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" }
        }
      }
    }
  }
}
```
{{< /code >}}


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

{{< note "important" "Tip">}}
Keep at least N + 1 sub-achievements if you plan to show N per day. This prevents duplicates and keeps rotation interesting.
{{< /note >}}

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](../../../concepts/personalizers/) 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:

{{< code type="server" filename="main.go" hideable="false" >}}
```go
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
}
```
{{< /code >}}

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.

{{< code type="server" filename="main.go" hideable="false" >}}
```go
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
}
```
{{< /code >}}

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.

{{< note "important" "Use UTC format">}}
Using `UTC` ensures the same seed globally and avoids per-region drift.
{{< /note >}}

## Register the personalizer in InitModule

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

{{< code type="server" filename="main.go" hideable="false" >}}
```go
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
}
```
{{< /code >}}

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:

```go
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

* [Personalizer concept guide](../../../concepts/personalizers/)
* [Achievements concept guide](../../../concepts/achievements/)
* [Server-side achievements](../../../server-framework/achievements/)
* [Achievements in Unity](../../../unity/achievements/)
* [Achievements in Unreal](../../../unreal/achievements/)
