# Inventory and catalogs

Games do not have to have an economy. A game with no currency, no items and nothing to buy is a
complete game. Read this if yours has any of them — a coin count, a bag of items, a shop, a loot
box, a battle pass.

If it does, the platform holds it, the same way it holds a save. Balances and items live on the
server and are reachable with the token your frame already holds, so they survive a device change
and a browser wipe, and a modified client cannot invent them — with one large exception this
document is careful to state, in [§14](#14-there-is-no-client-side-integrity-boundary).

## 1. Two halves, and they happen at different times

This is the thing to get straight first, because — as with save slots — only one half is code:

|                                                                                     | Who                      | When                           |
| ----------------------------------------------------------------------------------- | ------------------------ | ------------------------------ |
| **Authoring definitions** — the currencies, items, shops and loot tables that exist | you, the developer       | once, in your game config      |
| **Reading and changing what a player holds**                                        | your game's running code | every session, once per player |

A currency, an item type, a shop, a loot table — these are **definitions**. You publish them in
your game config, the same pipeline that ships save slots and room templates: versioned, no new
build, rollback-able. What a specific player owns is an **instance** of a definition, and that is
what your game reads and changes at runtime.

Game code cannot create a definition, and this is the wall almost everyone hits first: a read or
a grant that names an id you never published resolves to nothing, at the first call and every
retry. So this document starts with the config, not the API.

```bash
gemctl config schema                                # the authoritative field list
gemctl config upload --wait                         # registers a version; validation is ASYNC
gemctl config promote <version> --channel <channel> # this is what makes it live
```

**Uploading is not shipping.** Until `gemctl config promote` puts the version on the channel your
game runs, the definitions do not exist for that channel — see
[`saving-player-progress.md`](saving-player-progress.md#1-declare-the-slot-in-your-game-config),
which is the same rule with a save slot in place of a currency.

## 2. Definition versus instance

Hold the distinction, because the whole surface is built on it.

- A **currency definition** is "coins exist, minimum 0, maximum a million." A player's **balance**
  is an instance: "this player has 250 coins."
- An **item definition** is "a healing potion, stackable to 99." An **item instance** is "this
  player holds 3 of them," or, for a non-stacking item, "this specific sword, rolled with these
  properties."
- A **catalog** is a shop: a set of purchasable **recipes**. A player buying from it produces an
  **order** — a record that the change happened.
- A **loot table** is a weighted pool. A recipe that references it produces one **roll** per draw.

You read definitions from the published config. You read instances from the player. They are
different calls returning different shapes, and conflating them is how a bag renders raw ids
because it never loaded the definitions that name them.

## 3. The player, and their characters

Everything a player owns is reached through `gem.me`, the signed-in player:

```js
await gem.me.currencies.get('coins');
await gem.me.inventory.snapshot();
```

If your game gives a player several characters, each character carries the **same accessors**,
scoped to that character:

```js
const hero = await gem.characters.get(characterId);
await hero.currencies.get('coins'); // the hero's coins, not the account's
```

Code written against `gem.me` works unchanged against a character, because it is one interface on
both. Characters are declared and managed exactly as
[`saving-player-progress.md`](saving-player-progress.md) describes for saves; there is nothing
economy-specific about them, and this section is all you need here.

## 4. Reading the config, and the sync/async cliff

Load the published config once, then read from it synchronously. The load is a round trip; the
lookups are memory:

```js
const config = await gem.gameConfig.load(); // a round trip; do it once
const potion = config.item('healing-potion'); // synchronous, from memory, by id or slug
const coin = config.currency('coins'); // synchronous
```

Two things that will bite:

- **`load()` resolving `null` is a real state, not an error** — twice over. A game with no config
  at all resolves `null`, and so does a game whose config version has not finished building. A
  game with no economy should not have to catch anything, so `null` is a value you handle, not an
  exception you wrap.
- **Do not remember the field names.** Read them from `gemctl config schema` and from what
  `load()` returns, never from a list you typed once. A curated field list drifts silently: a
  real one once read a `fungible` flag under an invented `is_fungible`, a `stack_size` under an
  invented `max_stack_size`, and rendered an `icon_url` on currencies that has never existed on
  the wire. None of those raises. All of them just make a game quietly wrong. The schema is the
  authority precisely because it cannot go stale against itself.

## 5. Reading what a player holds

One call returns everything the player owns — balances, items and progressions — in a single
snapshot:

```js
const snap = await gem.me.inventory.snapshot();
snap.currencies.data; // balances
snap.items.data; // item instances
snap.progressions.data; // progression state
```

The snapshot is **cached**. Repeat calls, and reads derived from it — `gem.me.currencies`,
`gem.me.progressions`, item-instance lookups — serve from the one cached read rather than issuing
a request each, so a HUD polling a balance does not re-download the whole bag. Any change the SDK
makes drops the cache; [§6](#6-when-to-re-read) is about the changes it cannot see.

Each of the three sections carries its **own cursor**, because any one of them can be large
independently, and the snapshot carries an **`etag`** — the inventory's version — which
[§11](#11-conditional-writes-and-why-most-writes-do-not-want-them) uses to make a write
conditional.

## 6. When to re-read

**There is no push into a sandboxed frame.** A gift arriving from another player, a grant from
your dedicated server, a change made on the player's other device — none of these produces an
event in your game, because the frame has no socket to hear about them. A change your own game
made through the SDK drops the cache; nothing else does.

So the SDK's change event fires for your own writes and no others. Build on that and a port from
a native engine — which _did_ push external changes — is wrong in exactly the places the engine
was right.

The remedy is not a poll. **Do not poll**: a fixed-period re-read is a per-session load on the
backend that scales with your player count and buys staleness you could have designed around.
Instead:

- Re-read on screen-open. When the tab becomes visible, drop the cache and read again:

  ```js
  gem.me.inventory.refresh(); // drop what is cached
  await gem.me.inventory.snapshot(); // re-fetch
  ```

  Add a little randomness to when you fire it so a whole cohort resuming at once does not hit the
  backend in a spike.

- Re-read after any action that could involve another player — a trade, a match result.
- Treat the server's answer at the moment you _change_ something as the authority. A shop that
  re-checks affordability when the player clicks Buy, and lets the purchase itself be the final
  word, never needs the balance to be live between clicks.

Design the UI so staleness is harmless, and you will not want a poll.

## 7. Currencies

A balance read is a lookup, not a list to iterate, because **a currency the player holds none of
is absent, not present as zero**:

```js
const coins = await gem.me.currencies.get('coins');
coins.total; // a number, or null when the player has no record of this currency
```

**`null` is not `0`.** Reach for `total ?? 0` and you have decided that "has never held any" and
"holds none" are the same thing — and one of them may mean your game does not define that currency
at all. If you want to show a missing balance as zero, do that where it renders, so the decision
is visible.

**Buckets** split a balance by how it was acquired — a daily allowance, a play-earned pool, a
real-money pool. They are off by default and asked for per read:

```js
const c = await gem.me.currencies.get('coins', { buckets: true });
c.buckets; // [{ bucket: 'earned', balance: 200 }, { bucket: 'purchased', balance: 50 }], or absent
```

They are off by default because they are a **disclosure**, not a cost: a bucket names whether
value came from a real-money purchase versus play, so ask for them only on the screen that shows
the split. An empty array means the currency is bucketed and this player has none; an absent field
means you did not ask.

## 8. Items

An item definition is either **fungible** or not, and it changes what an instance is:

- A **fungible** item stacks. A player's 3 potions are one instance with a quantity of 3.
- A **non-fungible** item does not. Each is a distinct instance with its own id, and each may
  carry its own rolled **properties** — the affixes a generated weapon draws from a pool.

Read every instance of a definition without walking the whole bag:

```js
const swords = await gem.me.inventory.instances('iron-sword');
```

⚠️ **An item's `properties` and `customData` are untrusted, and rendering them wrong is a
cross-player attack.** They are typed `unknown` **because the platform does not validate their
contents** — every read of them is a parse, not a trusted object. And a transfer moves an instance
between players, so one player's item text reaches another player's screen. Any string you pull
from them is untrusted input: render it through `textContent` or your framework's escaping, and
**never** through `innerHTML`, and **never** as an `img.src` or `a.href` — either of those is a
`javascript:` / `data:` sink. The same caution applies to strings read from the config, which
arrives over a signed URL with no integrity check on its body.

## 9. Catalogs and recipes

A **catalog** is a shop; a **recipe** is one purchasable thing in it. A recipe declares its costs
(what it consumes), its grants (what it gives), an optional cooldown, a use cap, availability
windows, and optionally a loot table to roll. There are several recipe kinds — a purchase, a
craft, a consume, and so on; `gemctl config schema` is the list, and it is the authority because
that set is versioned.

Running a recipe is the one call that matters here:

```js
await gem.me.mutations.executeRecipe(catalogId, recipeId);
```

**A recipe is the only server-authoritative path in the economy.** When you execute one, the
server charges its costs, checks its prerequisites, enforces its cooldown and rolls any loot
table — none of which your client can forge. Everything else a shop shows — the price, whether
the player can afford it, the cooldown, uses remaining, the base odds — is derivable on the client
from the config and the snapshot you already loaded, with no per-item server call. [The
tutorial](game-economy-tutorial.md) builds exactly that shop.

Before you execute, you can ask whether a recipe can run, and that answer carries the cooldown so
you do not need a second read for it:

```js
const av = await gem.me.mutations.recipeAvailability(catalogId, recipeId);
av.canExecute; // boolean
av.reasons; // why not, if not
av.cooldownExpiresAt; // when the cooldown lifts, if it is on one
```

To read cooldowns for a whole shop at once, rather than one recipe at a time, there is a bulk
usage read — one request covering every recipe the player has used:

```js
const usage = await gem.me.mutations.recipeUsageAll();
usage.usages; // one entry per recipe, with isOnCooldown and cooldownExpiresAt
usage.truncated; // true if the server had more than it returned — then a missing recipe is UNKNOWN, not idle
```

Prefer `recipeUsageAll` to render a shop's cooldowns without a call per row, and
`recipeAvailability` to make the final check on the one recipe a player chose.

## 10. Orders — the record that something happened

Every change to a player's holdings produces an **order**. Order history is read-only and answers
"what happened," including the one question that matters after a write — "did it land":

```js
const result = await gem.me.mutations.executeRecipe(catalogId, recipeId);
const order = await gem.me.orders.get(result.order_id); // did it land?
await gem.me.orders.list(); // a page of recent orders
```

Two things about the history:

- **The `source` of an order is not always your game.** The history holds every order for the
  player in your game, including ones your game did not create — a reward, a system grant — so do
  not assume every row is a purchase you made.
- **It is not an accounting ledger.** It is a record of orders, not a running balance, and summing
  it will not reconcile against a current total. Read a balance from the snapshot; read history to
  see events.

Creating an order directly — as opposed to through a recipe — is possible and is covered where it
belongs, in the reference, because it is **not** how you build a shop. See
[§14](#14-there-is-no-client-side-integrity-boundary) for why.

## 11. Conditional writes, and why most writes do not want them

You can make a write conditional on the inventory not having changed since you read it, by sending
the snapshot's `etag`:

```js
const snap = await gem.me.inventory.snapshot();
await gem.me.mutations.create(ops, { ifMatch: snap.etag });
```

A `412` then means someone else wrote in between. The rule for a 412 is **re-read and re-decide —
never resend the same body.**

But **most game writes should not send `ifMatch`.** The common operations are deltas — spend this,
grant that — and the server already bounds them, so they are safe under concurrency without a
condition. Adding `ifMatch` converts harmless concurrency into 412s and forces a full re-read each
time. It is genuinely wanted only for a value your client computed as an absolute that the server
cannot re-derive, or a write that must not double-apply across two devices. It is a
contention-dependent choice, not a default.

**A 412 and a timeout are opposite cases, and a game that treats both as "retry" corrupts data.**
A 412 proves the write did _not_ happen — re-decide freely. A timeout proves _nothing_: the write
may have applied. Do not resend it; read the order back by id to find out what happened.

## 12. Loot tables and odds

A loot table is rolled **server-side only**. You never roll one on the client; you execute a
recipe that references it, and the server draws from it with weights your client cannot influence.

You can read a table's weights, to show a player their chances:

```js
const rates = await gem.gameConfig.dropRates('common-chest');
rates.entries; // each with a weight and a server-computed drop rate
```

⚠️ **These are the table's odds, not this player's.** Three things move the real chance and none of
them is in this response: a table may hold per-player pity state that no route exposes; entries can
be gated on what the player already owns or has progressed past, so a table that cannot drop what
you hold has different effective odds for everyone; and a mid-session config change is never seen,
because the config does not re-poll. Show this number as a "base chance," or not as a number at
all. The only authority on what a player actually receives is the result of executing the recipe.

## 13. Progressions

A **progression** is a track a player advances along — a battle pass, a level, a mastery. You read
its state from the snapshot, and you advance and claim it through mutations:

```js
const levels = await gem.me.progressions.list();
const one = await gem.me.progressions.get('season-1'); // null if the player has no record
```

A progression that grants rewards on a milestone is claimed like any other write, and the claim
produces an order you can read back. As with currencies, `get` returns `null` for a player with no
record of a progression, which is not the same as one they have started and sit at zero on.

## 14. There is no client-side integrity boundary

This is the exception the top of the document promised, and it is the most important paragraph
here.

> Within your own game's scope there is **no client-side integrity boundary**. Any balance, item
> or progression a browser client can author, a modified browser client can author differently.
> Recipes move the arithmetic to the server and are the only thing that does. Do not attach
> anything of value — a leaderboard, a tournament, a payout, a cross-player market — to a number a
> client authored.

Your game can grant itself currency and items within its own scope, and there are legitimate uses
for that — a single-player game rewarding a level, a prototype. But a grant your client authored is
a grant a modified client can author differently, so it is never a basis for competition. When the
value matters, run a **recipe**: the server does the arithmetic, and that is the only thing a
modified client cannot rewrite. The SDK steering you toward recipes is ergonomics, not a fence —
the game shares a page with the SDK and can reach any route directly — so the discipline has to be
yours.

## 15. Batch orders

You can commit several orders together. One caution, because it is a data-loss trap:

⚠️ **A batch is not safe to retry.** The idempotency guard the SDK applies per operation does not
protect a whole batch the same way, so a batch that times out must not be resent — a resend can
double-apply the entire batch. As with any write, a timeout is not a failure you retry; read back
what happened. If a set of changes can be one order with more operations rather than several
orders in a batch, prefer that.

## What you cannot do here

The platform does not hand a game a platform-wide wallet or ticket balance — there is no built-in
premium currency you draw against. Currencies are the ones your game defines. If you need a
real-money purchase flow, that is a platform capability reached elsewhere, not something this
surface grants.

## Errors

| What you get                                  | What happened                                                                                                                             | What to do                                                                                                                                                    |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A read or grant resolves to nothing for an id | The definition is not on this channel — uploaded but not promoted, or a channel mismatch between the running game and the promoted config | Promote the config version to the channel your game runs; see [`saving-player-progress.md`](saving-player-progress.md#1-declare-the-slot-in-your-game-config) |
| `null` from `load()`                          | This game has no config on this channel, or its version has not built yet                                                                 | A real state; render the no-economy path rather than catching an error                                                                                        |
| `402` on a consume                            | The player cannot afford it                                                                                                               | Re-read the balance and re-render; do not retry the same write                                                                                                |
| `412` on a conditional write                  | Someone else wrote since your read                                                                                                        | Re-read and re-decide — never resend the same body                                                                                                            |
| A recipe reports it cannot run                | A cooldown, a use cap, an unmet prerequisite, or an availability window                                                                   | Show the reason from `recipeAvailability`; the cooldown's end is on that same response                                                                        |
| A write times out                             | Unknown — it may have applied                                                                                                             | Do not resend; read the order back by id                                                                                                                      |

## What to read next

- [`game-economy-tutorial.md`](game-economy-tutorial.md) — build a shop, step by step, the safe way.
- [`trading-and-gifting.md`](trading-and-gifting.md) — move items and currency between players.
- [`saving-player-progress.md`](saving-player-progress.md) — a save often holds an inventory; the two are neighbours.
