# Build a gacha system

**URL:** https://heroiclabs.com/docs/hiro/guides/gameplay-mechanics/gacha/
**Keywords:** gacha, gacha mechanics, gacha system, weighted reward, lootbox, loot box, hiro, pity, duplicate, dupe
**Categories:** hiro, guides, gameplay-mechanics

---


# Build a gacha system

Gacha is a game mechanic in which players spend currency for a random item drawn from a weighted pool of rewards. Also known as a lootbox, this mechanic drives some of the most successful titles around: card packs in Hearthstone, character banners in Genshin Impact, and weapon cases in Counter-Strike. The name gacha is short for "gachapon", after the Japanese capsule machines that dispense random prizes on every play.

Using [Hiro](../../../concepts/introduction/), you'll build a gacha system along with the core features players expect: multiple rarity tiers, pity mechanics, and duplicate item handling.

{{< screenshot src="/images/gacha-system-default.png" alt="Players spend currency called Tickets to try their luck obtaining random rewards of varying rarity" caption="Players spend currency called Tickets to try their luck obtaining random rewards of varying rarity" >}}

## Terminology

- **Pull**: Spending currency, such as a ticket, to receive a randomly weighted reward. In this guide, pulling is also referred to as consuming gacha tickets.
- **Pity**: A mechanic that guarantees a reward once a player reaches a set number of pulls without success, known as the "pity threshold". Pity counters the unlucky streaks that can happen with weighted odds.
- **Rarity**: How scarce a reward is, graded here on a star scale (four-star, five-star, and six-star). The higher the rarity, the lower its drop rate. Many gacha games feature multiple high-rarity "tracks", each with its own drop rate, its own reward pool, and its own independent pity counter and threshold. A player ends up with many four-stars, fewer five-stars, and the occasional six-star, while pity guarantees steady progress on each high-rarity track.

## Before you begin

Make sure you have:

- A Nakama instance [running locally](../../../../hiro/concepts/getting-started/install/) with Hiro installed
- Familiarity with Hiro [hooks](/hiro/server-framework/inventory/#setonconsumereward)

The examples in this guide are built in Unity. To follow along, clone the [Gacha](https://github.com/heroiclabs/sample-projects/tree/main/guides/Gacha) project and open it in your Unity Editor.

### Running the server locally

The sample project includes a Docker Compose file that starts Nakama and the database. You'll need to run it in order to see the gacha system in action as you work through the guide:

1. Go to the `server/` directory.
2. Open `.env.example` and follow the instructions to create `.env.local` where you can insert your Hiro license key.
3. From the command line, run `docker compose up --build`.
4. Access the Nakama console at `http://localhost:7351`.

## Modeling gacha mechanics with Hiro

You'll build the gacha system using a mix of three Hiro systems:

### Inventory: defining items and hooks

The Inventory system defines every item available in your game. Players spend tickets to perform pulls, while possible reward items are grouped into _item sets_. You'll also register hooks, such as `SetOnConsumeReward`, to handle pity mechanics and duplicate reward detection. See [Hiro Inventory](../../../concepts/inventory/).

### Stats: tracking pity counters

To track a player's pulls and manage pity, you'll implement the Hiro Stats system. A count of a player's unsuccessful pulls is tracked as a private stat called a "pity counter." See [Hiro Stats](../../../concepts/stats/).

### Economy: granting rewards and currency

Hiro's Economy system handles everything from granting rewards to populating new user accounts with starter items. In this guide, it gives new players some starting tickets to spend on pulls. You'll also write custom logic to reward players who've hit the pity threshold. See [Hiro Economy](../../../concepts/economy/).

<!-- TODO: Good candidate for technical diagram here demonstrating the e2e flow -->

## Initialize Hiro systems

Register your Hiro systems to load their configuration at startup. You'll also unregister one built-in RPC to keep pity tamper-proof. From there, Hiro handles weighted reward selection on every pull, while your `SetOnConsumeReward` hook layers on pity and duplicate handling.

{{< pretitle "Step 1" >}}

### Register the systems in InitModule

Inside `main.go`, register the Economy, Inventory, and Stats systems in your `InitModule` function. These systems read from JSON configuration files that you'll create when you [set up the Hiro system definitions](#set-up-hiro-system-definitions).

{{< code type="server" filename="main.go" hideable="false" >}}

```go
systems, err := hiro.Init(ctx, logger, nk, initializer, binPath, hiroLicense,
    hiro.WithBaseSystem(fmt.Sprintf("definitions/%s/base-system.json", env), true),
    hiro.WithEconomySystem(fmt.Sprintf("definitions/%s/base-economy.json", env), true),
    hiro.WithInventorySystem(fmt.Sprintf("definitions/%s/base-inventory.json", env), true),
    hiro.WithStatsSystem(fmt.Sprintf("definitions/%s/base-stats.json", env), true))
if err != nil {
    return err
}
```

{{< /code >}}

{{< pretitle "Step 2" >}}

### Unregister the stats update RPC

By default, Hiro registers a stats update RPC that clients can call to modify their own stats. Because the pity counter is stored as a stat, leaving this RPC exposed creates a client-side manipulation risk. Unregister it so pity can only be updated server-side by the `SetOnConsumeReward` hook:

{{< code type="server" filename="main.go" hideable="false">}}

```go
// Prevent clients from updating their stats directly, which would let them reset pity counters.
if err = hiro.UnregisterRpc(initializer,
    hiro.RpcId_RPC_ID_STATS_UPDATE,
); err != nil {
    return err
}
```

{{< /code >}}

## Set up Hiro system definitions

Each Hiro system reads its configuration from a JSON file. These files are found under `server/definitions/dev1` inside the sample project.

{{< pretitle "Step 1" >}}

### Define a gacha ticket

Declare an `items` block, then add your item definitions inside it. The snippets in this section each show one entry to add; together they make up the single `items` object in `base-inventory.json`. Start with the `gacha_ticket` item:

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

```json
{
  "items": {
    "gacha_ticket": {
      "name": "Gacha Ticket",
      "description": "Crack open to see who you get!",
      "category": "gacha_ticket",
      "max_count": 999,
      "stackable": true,
      "consumable": true,
      "consume_reward": {
        "weighted": [
          {
            "item_sets": [{ "set": ["gacha_ticket_four_star"], "min": 1 }],
            "weight": 91
          },
          {
            "item_sets": [{ "set": ["gacha_ticket_five_star"], "min": 1 }],
            "weight": 8
          },
          {
            "item_sets": [{ "set": ["gacha_ticket_six_star"], "min": 1 }],
            "weight": 1
          }
        ],
        "max_rolls": 1
      },
      "numeric_properties": {
        "five_star_pity": 10,
        "six_star_pity": 80
      }
    }
  }
}
```

{{< /code >}}

The properties that drive gacha behavior:

| Field                | Purpose                                                                                |
| -------------------- | -------------------------------------------------------------------------------------- |
| `max_count`          | Maximum number of this item a player can hold.                                         |
| `stackable`          | Whether copies stack into one inventory slot.                                          |
| `consumable`         | Whether the item can be consumed. Consuming a ticket is what triggers a pull.          |
| `consume_reward`     | What the player receives on consume. The `weighted` array sets the odds for each tier. |
| `numeric_properties` | Custom values on the item. Here they hold the pity thresholds.                         |

**Weighted rolls**: Each entry under `weighted` pairs an item set with a `weight`, where higher weights are more likely. An item set is a named group of possible reward items, which you'll define later on. The weights here add up to 100, so each tier's chance reads directly as a percentage (91%, 8%, and 1%). To learn more, see [Rewards](../../../concepts/inventory/#rewards).

**Pity thresholds**: `numeric_properties` stores how many pulls without a given rarity will force a guaranteed one. With `six_star_pity` set to 80, a six-star is guaranteed at least once every 80 pulls: after 79 pulls without one, the 80th is forced to be a six-star. Your custom `SetOnConsumeReward` hook reads these values at runtime.

{{< pretitle "Step 2" >}}

### Define a premium ticket

A common gacha pattern offers more than one type of ticket: a standard ticket that's plentiful and grants regular rewards, and a premium ticket that's harder to come by, often purchased with real money, and with more valuable rewards. You differentiate them with item sets, so each ticket pulls from its own pool.

Create a `gacha_ticket_premium` item under the standard one, with lower pity thresholds and its own exclusive item sets. Name those sets after the ticket ID (`gacha_ticket_premium_four_star`, and so on). This is an intentional naming convention: the pity hook derives which item set to roll from by appending the rarity to the ticket's ID to derive the item set's name.

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

```json
{
  "items": {
    "gacha_ticket_premium": {
      "name": "Premium Gacha Ticket",
      "description": "Crack open to see if you get the best of the best!",
      "category": "gacha_ticket",
      "max_count": 999,
      "stackable": true,
      "consumable": true,
      "consume_reward": {
        "weighted": [
          {
            "item_sets": [
              { "set": ["gacha_ticket_premium_four_star"], "min": 1 }
            ],
            "weight": 91
          },
          {
            "item_sets": [
              { "set": ["gacha_ticket_premium_five_star"], "min": 1 }
            ],
            "weight": 8
          },
          {
            "item_sets": [
              { "set": ["gacha_ticket_premium_six_star"], "min": 1 }
            ],
            "weight": 1
          }
        ],
        "max_rolls": 1
      },
      "numeric_properties": {
        "five_star_pity": 5,
        "six_star_pity": 40
      }
    }
  }
}
```

{{< /code >}}

{{< pretitle "Step 3" >}}

### Define reward items

Define the reward items and assign each one to an item set. A pull resolves in two stages: the weighted roll first selects a tier (an item set), then Hiro grants one item from that set:

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

```json
{
  "items": {
    "shield": {
      "name": "Shield",
      "category": "item",
      "description": "The best defense around.",
      "item_sets": ["gacha_ticket_four_star"],
      "numeric_properties": {
        "star_rarity": 4
      }
    },
    "sword": {
      "name": "Sword",
      "category": "item",
      "description": "A sharp companion.",
      "item_sets": ["gacha_ticket_four_star"],
      "numeric_properties": {
        "star_rarity": 4
      }
    },
    "star": {
      "name": "Star",
      "category": "item",
      "description": "A shining find.",
      "item_sets": ["gacha_ticket_four_star"],
      "numeric_properties": {
        "star_rarity": 4
      }
    },
    "clover": {
      "name": "Clover",
      "category": "item",
      "description": "How lucky!",
      "item_sets": ["gacha_ticket_five_star"],
      "numeric_properties": {
        "star_rarity": 5
      }
    },
    "demon_eye": {
      "name": "Demon Eye",
      "category": "item",
      "description": "This seems dangerous.",
      "item_sets": ["gacha_ticket_six_star"],
      "numeric_properties": {
        "star_rarity": 6
      }
    }
  }
}
```

{{< /code >}}

Here, the four-star tier (`gacha_ticket_four_star`) holds three items, so a four-star roll grants one of them at random. Add as many items to a set as you like to build out each rarity's pool. The pity hook reads each item's `star_rarity` property to determine the rolled tier; the same property is used client-side for coloring and sorting. See [item sets](../../../concepts/inventory/#item-sets).

{{< pretitle "Step 4" >}}

### Define duplicate tokens

For each reward item, define a corresponding token item. When a player pulls an item they already own, the hook grants this token instead of the duplicate:

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

```json
{
  "items": {
    "shield_token": {
      "name": "Shield Token",
      "category": "item_token",
      "description": "Represents a duplicate copy of Shield.",
      "stackable": true,
      "numeric_properties": {
        "star_rarity": 4
      }
    }
  }
}
```

{{< /code >}}

Token items use the naming convention `{item_id}_token`. The hook constructs this ID by appending `_token` to the duplicated item's ID, so every reward item needs a matching token. Make tokens `stackable` so repeated duplicates collapse into a single inventory slot.

{{< pretitle "Step 5" >}}

### Define starting items

Use the `initialize_user` block to grant new players their starting currencies and tickets:

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

```json
{
  "initialize_user": {
    "currencies": {
      "coins": 100,
      "gems": 0
    },
    "items": {
      "gacha_ticket": 999,
      "gacha_ticket_premium": 99
    }
  }
}
```

{{< /code >}}

User initialization runs once, when an account is first created, granting every new player these starting currencies and tickets. The high ticket counts make it easy to test pity, which can take dozens of pulls to trigger.

{{< pretitle "Step 6" >}}

### Define the stats system

The pity counters are private stats, two per ticket: `{ticket_id}_five_star_pity` and `{ticket_id}_six_star_pity` (for example, `gacha_ticket_six_star_pity`). You don't need to declare them here, though. The `SetOnConsumeReward` hook writes them server-side when you [add the gacha logic to the hook](#add-the-gacha-logic-to-the-setonconsumereward-hook), and Hiro creates each stat on demand the first time it's updated. Because players never write these stats directly (you unregistered the stats update RPC when you [initialized the systems](#initialize-the-hiro-systems)), an empty definition is all you need:

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

```json
{}
```

{{< /code >}}

The file still has to exist and be registered with `WithStatsSystem`, so the Stats system is available to read and update at runtime.

## Add gacha logic to the `SetOnConsumeReward` hook

All the gacha-specific behavior, pity and duplicate handling, runs in one hook: `SetOnConsumeReward`. Hiro calls it each time a player consumes an item, after rolling the weighted reward but before granting it. The hook receives the rolled reward and returns the reward to actually grant, so you can inspect the roll and override it. See [SetOnConsumeReward](https://heroiclabs.com/docs/hiro/server-framework/inventory/#setonconsumereward) in the reference.

The logic spans two files. In `main.go`, register the hook during initialization:

{{< code type="server" filename="main.go" hideable="false" >}}

```go
systems.GetInventorySystem().SetOnConsumeReward(OnConsumeReward(
    systems.GetEconomySystem(), systems.GetInventorySystem(), systems.GetStatsSystem()))
```

{{< /code >}}

`OnConsumeReward` returns the function Hiro invokes on every consume, passing the systems through to `handleGachaConsumeReward` in `gacha.go`, where the work happens. That function runs three steps, and only for gacha tickets: it returns early for any other consumable.

{{< code type="server" filename="gacha.go" hideable="false" >}}

```go
func handleGachaConsumeReward(
    ctx context.Context, logger runtime.Logger, nk runtime.NakamaModule,
    economySystem hiro.EconomySystem, inventorySystem hiro.InventorySystem, statsSystem hiro.StatsSystem,
    userID, sourceID string, source *hiro.InventoryConfigItem, reward *hiro.Reward,
) (*hiro.Reward, error) {
    // Only act on gacha tickets; any other consumable passes through unchanged.
    if source.Category != categoryGachaTicket {
        return reward, nil
    }

    // Load the inventory config and the player's stats for the pity checks.
    config, ok := inventorySystem.GetConfig().(*hiro.InventoryConfig)
    if !ok {
        return nil, nil
    }
    statList, err := statsSystem.List(ctx, logger, nk, userID, []string{userID})
    if err != nil {
        return nil, err
    }

    // 1. Apply pity: override the roll if the player has reached a threshold.
    // 2. Update the pity counters based on the final reward.
    // 3. Swap the reward for a token if it's a duplicate.

    return reward, nil
}
```

{{< /code >}}

The next three sections fill in those steps.

{{< pretitle "Step 1" >}}

### Apply pity to the roll

Before anything is granted, the hook checks whether the player is owed a guaranteed reward. It does this by reading the player's two pity counters for this ticket and tests each against the matching threshold in the ticket's `numeric_properties`:

1. Compare the six-star counter to `six_star_pity`. If the threshold is reached, reward the player a six-star item.
2. Otherwise, compare the five-star counter to `five_star_pity`. If that threshold is reached, reward the player a five-star item.

Six-star is checked first, so a player who has reached both thresholds gets the rarer reward.

{{< code type="server" filename="gacha.go" hideable="false" >}}

```go
sixStarPity := getPityStat(statList, userID, sourceID+statSuffixSixStarPity)
maxSixStarPity, hasSixStarPity := source.NumericProperties[propSixStarPity]

if hasSixStarPity && sixStarPity >= int(maxSixStarPity-1) {
    // Guarantee a six-star by rolling from only the six-star pool.
    cfg := buildPityRewardConfig(sourceID, 0, pityWeightGuaranteedSixStar)
    reward, err = rollPityReward(ctx, logger, nk, economySystem, config, userID, reward, cfg, raritySixStar)
    if err != nil {
        return nil, err
    }
} else {
    fiveStarPity := getPityStat(statList, userID, sourceID+statSuffixFiveStarPity)
    maxFiveStarPity, hasFiveStarPity := source.NumericProperties[propFiveStarPity]

    if hasFiveStarPity && fiveStarPity >= int(maxFiveStarPity-1) {
        // Roll with a 99% five-star weight and a 1% six-star weight.
        cfg := buildPityRewardConfig(sourceID, pityWeightFiveStar, pityWeightSixStar)
        reward, err = rollPityReward(ctx, logger, nk, economySystem, config, userID, reward, cfg, rarityFiveStar)
        if err != nil {
            return nil, err
        }
    }
}
```

{{< /code >}}

Each counter holds the number of consecutive pulls _without_ the player getting an item of that rarity, so the test is `counter >= threshold - 1`: when the counter reaches one below the threshold, the current pull is at the threshold and pity fires. With `six_star_pity` set to 80, that's the 80th pull, matching the "guaranteed at least once every 80 pulls" rule from earlier.

For example, suppose a player opens 79 standard tickets in a row without a six-star. Their `gacha_ticket_six_star_pity` counter now reads 79. On the 80th pull, the check `79 >= 80 - 1` passes, so the hook guarantees a six-star, no matter what the original weighted roll produced.

When pity triggers, the hook doesn't hand the player a fixed item. Instead it performs a _pity roll_: a fresh weighted roll from a narrower, high-rarity pool. `buildPityRewardConfig` builds that pool from the ticket's high-rarity item sets:

- Six-star pity puts all the weight on the six-star set, so the result is always a six-star.
- Five-star pity uses a 99/1 weight split across the five-star and six-star sets, so a five-star is near-certain while a lucky six-star is still possible.

The five-star track is a near-guarantee rather than a hard one on purpose: a hard five-star guarantee would cap that pull at five-star and erase any chance of the rarer reward. The 99/1 split keeps the six-star hope alive even on a pity pull. It's a design choice you can tune; set it to 100/0 for a strict five-star guarantee.

`rollPityReward` then performs the pity roll, but only when it's needed:

{{< code type="server" filename="gacha.go" hideable="false" >}}

```go
func rollPityReward(
    ctx context.Context,
    logger runtime.Logger,
    nk runtime.NakamaModule,
    economySystem hiro.EconomySystem,
    config *hiro.InventoryConfig,
    userID string,
    reward *hiro.Reward,
    rewardConfig *hiro.EconomyConfigReward,
    minRarity float64,
) (*hiro.Reward, error) {
    // Skip the re-roll if the naturally rolled item already meets the rarity threshold.
    itemID := firstRewardItemID(reward)
    if itemID == "" || getItemRarity(config, itemID) >= minRarity {
        return reward, nil
    }

    rolledReward, err := economySystem.RewardRoll(ctx, logger, nk, userID, rewardConfig)
    if err != nil {
        return nil, err
    }
    return rolledReward, nil
}
```

{{< /code >}}

The pity roll only happens when it would actually improve the result. The player's _original roll_, the one Hiro already made from the ticket's normal odds, is passed in as `reward`. If it already met or beat the target rarity, it stands, since there's no point replacing a six-star with another six-star. Otherwise, `economySystem.RewardRoll` produces the pity reward from the high-rarity config.

{{< pretitle "Step 2" >}}

### Update the pity counters

With the final reward decided, the hook updates both pity counters to match it. The rule: a counter resets when its rarity (or higher) is pulled, and increments otherwise.

| Rolled rarity | Six-star counter | Five-star counter |
| ------------- | ---------------- | ----------------- |
| Six-star      | Reset to 0       | Reset to 0        |
| Five-star     | Increment by 1   | Reset to 0        |
| Four-star     | Increment by 1   | Increment by 1    |

A six-star pull resets both counters, since it satisfies both pity tracks at once. A five-star pull resets the five-star counter but still advances the player toward six-star pity. Anything lower advances both. For example, a player on a long four-star streak watches both counters climb together; the moment they pull a five-star, the five-star counter drops back to 0 while the six-star counter keeps rising, still tracking toward its guarantee.

Each update uses one of two operators. `STAT_UPDATE_OPERATOR_SET` writes an absolute value, used here to reset a counter to 0, while `STAT_UPDATE_OPERATOR_DELTA` adds to the current value, used to increment by 1. Every stat name is prefixed with `sourceID`, the consumed ticket's item ID, so a standard ticket writes `gacha_ticket_six_star_pity` while a premium ticket writes `gacha_ticket_premium_six_star_pity`. The two tickets keep entirely separate pity progress.

{{< code type="server" filename="gacha.go" hideable="false" >}}

```go
rarity := getItemRarity(config, itemID)

switch {
case rarity >= raritySixStar:
    // Reset both counters on a six-star pull.
    statUpdates = []*hiro.StatUpdate{
        {Name: sourceID + statSuffixSixStarPity, Value: 0, Operator: hiro.StatUpdateOperator_STAT_UPDATE_OPERATOR_SET},
        {Name: sourceID + statSuffixFiveStarPity, Value: 0, Operator: hiro.StatUpdateOperator_STAT_UPDATE_OPERATOR_SET},
    }
case rarity >= rarityFiveStar:
    // Increment six-star pity, reset five-star pity on a five-star pull.
    statUpdates = []*hiro.StatUpdate{
        {Name: sourceID + statSuffixSixStarPity, Value: 1, Operator: hiro.StatUpdateOperator_STAT_UPDATE_OPERATOR_DELTA},
        {Name: sourceID + statSuffixFiveStarPity, Value: 0, Operator: hiro.StatUpdateOperator_STAT_UPDATE_OPERATOR_SET},
    }
default:
    // Increment both counters on a four-star pull.
    statUpdates = []*hiro.StatUpdate{
        {Name: sourceID + statSuffixSixStarPity, Value: 1, Operator: hiro.StatUpdateOperator_STAT_UPDATE_OPERATOR_DELTA},
        {Name: sourceID + statSuffixFiveStarPity, Value: 1, Operator: hiro.StatUpdateOperator_STAT_UPDATE_OPERATOR_DELTA},
    }
}

if _, err := statsSystem.Update(ctx, logger, nk, userID, nil, statUpdates); err != nil {
    return err
}
```

{{< /code >}}

{{< pretitle "Step 3" >}}

### Swap duplicates for tokens

Finally, the hook checks whether the player already owns the rolled item. Gacha rewards are often unique collectibles, so a second copy isn't useful; instead, a duplicate becomes a token the player can spend later on other items. The hook lists the player's current inventory and looks for the rolled item: if it's already there, it swaps the reward's contents for the matching `{item_id}_token` item; otherwise the reward passes through unchanged.

{{< code type="server" filename="gacha.go" hideable="false" >}}

```go
itemID := firstRewardItemID(reward)
if itemID == "" {
    return reward, nil
}

inventoryItems, err := inventorySystem.ListInventoryItems(ctx, logger, nk, userID, "")
if err != nil {
    return nil, err
}

for _, existing := range inventoryItems.Items {
    if existing.Id == itemID {
        reward.Items = map[string]int64{
            itemID + tokenSuffix: 1,
        }
        return reward, nil
    }
}

return reward, nil
```

{{< /code >}}

From here, tokens can feed an upgrade system, a shop, or any other token "sink" you design.

## Test your gacha system

With the server running and the Unity project open, confirm each piece works end to end.

In the Unity client:

1. Press **Play** in the Unity Editor and sign in to create a player. New accounts start with the tickets you defined in `base-economy.json`.
2. Perform a single pull. The reward is drawn from the weighted odds you set, so most pulls return a four-star item.
3. Pull repeatedly and watch the rarer tiers show up far less often, in line with their weights.
4. Keep pulling past a threshold to see pity fire: after enough pulls without a six-star, the next pull is guaranteed one.
5. Pull an item you already own to see it arrive as a `_token` instead of a duplicate.

{{< screenshot src="/images/gacha-system-inventory.png" alt="Tickets and rewarded items are tracked in the player's inventory" caption="Tickets and rewarded items are tracked in the player's inventory" >}}

To confirm the same behavior server-side, open the Nakama console at `http://localhost:7351`:

- Find the player under **Accounts**, then inspect their storage to see the granted inventory items and token counts.
- Check the player's private stats to watch the pity counters (`gacha_ticket_six_star_pity` and `gacha_ticket_five_star_pity`) climb and reset as you pull.

Seeing the counters reset on a high-rarity pull, and a guaranteed reward at the threshold, confirms the `SetOnConsumeReward` hook is doing its job.

## Conclusion

You've built a complete gacha system: weighted pulls, pity mechanics, and duplicate handling, all driven by Hiro's configuration and a single consume-reward hook.

- The **inventory definitions** set each ticket's odds, item pools, and pity thresholds.
- The **`SetOnConsumeReward` hook** applies pity, updates the per-ticket counters, and swaps duplicates for tokens.
- The **stats system** stores the pity counters, written server-side so players can't tamper with them.

Use this same pattern to add loot boxes, trading card booster packs, crafting RNG, or slot machine mechanics into your game. Happy building!

## See also

- [Hiro Economy concepts](../../../concepts/economy/)
- [Hiro Inventory concepts](../../../concepts/inventory/)
- [Hiro Stats concepts](../../../concepts/stats/)
- [Hiro Inventory sample project](../../../../sample-projects/unity/hiro-inventory)
- [Heroic Labs forum](https://forum.heroiclabs.com/c/hiro/)
