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.
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.
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 liveUploading 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, 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:
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:
const hero = await gem.characters.get(characterId);
await hero.currencies.get('coins'); // the hero's coins, not the account'sCode 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 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:
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'); // synchronousTwo things that will bite:
load()resolvingnullis a real state, not an error — twice over. A game with no config at all resolvesnull, and so does a game whose config version has not finished building. A game with no economy should not have to catch anything, sonullis a value you handle, not an exception you wrap.Do not remember the field names. Read them from
gemctl config schemaand from whatload()returns, never from a list you typed once. A curated field list drifts silently: a real one once read afungibleflag under an inventedis_fungible, astack_sizeunder an inventedmax_stack_size, and rendered anicon_urlon 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:
const snap = await gem.me.inventory.snapshot();
snap.currencies.data; // balances
snap.items.data; // item instances
snap.progressions.data; // progression stateThe 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 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 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:
gem.me.inventory.refresh(); // drop what is cached await gem.me.inventory.snapshot(); // re-fetchAdd 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:
const coins = await gem.me.currencies.get('coins');
coins.total; // a number, or null when the player has no record of this currencynull 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:
const c = await gem.me.currencies.get('coins', { buckets: true });
c.buckets; // [{ bucket: 'earned', balance: 200 }, { bucket: 'purchased', balance: 50 }], or absentThey 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:
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:
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 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:
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 oneTo 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:
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 idlePrefer 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":
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 ordersTwo things about the history:
The
sourceof 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 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:
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:
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:
const levels = await gem.me.progressions.list();
const one = await gem.me.progressions.get('season-1'); // null if the player has no recordA 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 |
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— build a shop, step by step, the safe way.trading-and-gifting.md— move items and currency between players.saving-player-progress.md— a save often holds an inventory; the two are neighbours.