All developer documentation

Building a shop, step by step

This builds a working shop: a currency, an item, a purchase, and the screen a player buys from. It is the safe version — the one where the server does the arithmetic — and it is written to be followed in order.

Read inventory-and-catalogs.md first for the concepts; this is the recipe.

Before you start, you need two things this document assumes:

  • The gemctl command-line tool. It installs from the agent-plugin marketplace, not from npm — npm install gemctl finds nothing. If you do not have it, steps 1 and 2 cannot be done; get it first.

  • A developer account with your game registered. Uploading config needs developer permission on the game. A 403 means the account lacks it, and signing in again will not fix it.

Everything here targets the dev channel — the one only your team sees. The last step says why you must not change that.

The budget, so you know when you have it right#

A shop screen costs three requests to render, one when the player selects a row, and one to execute the purchase. If you find yourself calling the server once per shop row, you have taken a wrong turn — go back to step 4. The three-to-render are: load the config, read the player's inventory snapshot, and read recipe usage for cooldowns. Everything a row shows — its price, whether the player can afford it, its cooldown, uses remaining, base odds — comes from those, computed on the client.

1. Author the config#

Your currency, item and shop are config, not code. This is a minimal illustrative shape — run gemctl config schema for the authoritative field list, and do not copy a field list out of any document, because the schema is versioned and a copied list goes stale.

currencies:
  - id: coins
    display_name: Coins
    min_balance: 0
    max_balance: 999999

items:
  - id: healing-potion
    display_name: Healing Potion
    fungible: true

catalogs:
  - id: general-store
    recipes:
      - id: buy-potion
        recipe_type: purchase
        costs:
          - currency_id: coins
            amount: 25
        grants:
          - item_definition_id: healing-potion
            quantity: 1

A purchase recipe is the whole point: it names what it costs and what it grants, and the server enforces both. You will run it by name; you will never send the price yourself.

2. Upload and promote to the dev channel#

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

Uploading is not shipping. Until gemctl config promote puts the version on the channel your game runs, the shop does not exist for that channel and every read of it comes back empty. This is the same rule save slots follow; see saving-player-progress.md.

3. Load the config and the snapshot, once#

In the game, at shop-open, read the three things the whole screen is built from:

const config = await gem.gameConfig.load(); // the shop's definitions
const snap = await gem.me.inventory.snapshot(); // what the player holds
const usage = await gem.me.mutations.recipeUsageAll(); // cooldowns, one read for every recipe

config can be null. That means this channel has no config yet — the version was not promoted, or the game is running on a different channel than the one you promoted to. Render an "shop unavailable" state; it is a real condition, not an error to swallow.

4. Render the shop from what you already have — no per-row call#

Walk the catalog's recipes and render each from the config and the snapshot. Do not call the server inside this loop. The price is on the recipe; affordability is the player's balance against it; the cooldown is in the usage you already read.

One shape gotcha before the code: the config document is the raw published data, so its fields are snake_casedisplay_name, currency_id, item_definition_id. The player-side reads (recipeUsage, gem.me.currencies, recipeAvailability) are the SDK's own shapes and are camelCase. Do not mix them.

const store = config.catalogs.find((c) => c.id === 'general-store');
const balance = (await gem.me.currencies.get('coins')).total ?? 0; // from the snapshot, not a fourth request

for (const recipe of store.recipes) {
  const cost =
    recipe.costs?.find((c) => 'currency_id' in c && c.currency_id === 'coins')?.amount ?? 0;
  const affordable = balance >= cost;
  const used = usage.usages.find((u) => u.recipeId === recipe.id); // camelCase: SDK shape
  const onCooldown = used?.isOnCooldown ?? false;

  const grant = recipe.grants?.[0];
  const name =
    grant && 'item_definition_id' in grant
      ? config.item(grant.item_definition_id)?.display_name
      : recipe.id;

  const row = document.createElement('button');
  row.textContent = `${name} — ${cost}`; // textContent, never innerHTML
  row.disabled = !affordable || onCooldown;
  row.onclick = () => onBuy(recipe); // remember which recipe this row is — steps 5-6 need it
  shopEl.append(row);
}

Render with textContent, never innerHTML. A display name or an item's freeform properties is text you did not author — an item can arrive from another player by trade — so building markup from it is a cross-player script-injection sink. The same goes for any img.src or a.href you might set from a config or item string: don't. See inventory-and-catalogs.md.

5. When the player picks a row, check that one recipe#

The onBuy(recipe) the row wired up above runs the rest. Now — and only now, for the one recipe the player chose — ask the server whether it can run. This is the single per-selection call, and it carries the cooldown so you need nothing else:

async function onBuy(recipe) {
  const av = await gem.me.mutations.recipeAvailability('general-store', recipe.id);
  if (!av.canExecute) {
    showReason(av.reasons, av.cooldownExpiresAt); // e.g. "on cooldown until …"
    return;
  }
  // ... step 6 continues here
}

A 20-row shop that called this per row would be 20 requests for a screen that needs none until a click. Check availability on selection, not on render.

6. Execute the recipe — the server does the arithmetic#

Still inside onBuy, with the same recipe: run it by name. You send no price, no balance, no total — the server charges the cost and grants the item, and a modified client cannot change what it charges:

executeRecipe resolves with the server's own result — the order id, what was consumed and granted, and any loot rolls. It is the raw response shape, so its fields are snake_case like the config:

try {
  const result = await gem.me.mutations.executeRecipe('general-store', recipe.id);
  // result.order_id, result.status, result.granted, result.loot_results
  await confirmPurchase(result.order_id); // step 7
} catch (err) {
  handlePurchaseError(err);
}

Three failures to handle, and getting them right is the difference between a shop and a data-corruption bug:

  • A 412 and a timeout are opposite. You are not sending ifMatch here — a purchase is a delta the server bounds, so it needs no condition — so a 412 should not arise. But the general rule matters the moment you do send one elsewhere: a 412 proves the write did not happen; a timeout proves nothing and the purchase may have applied. Never resend a timed-out purchase. Read the order back (step 7) to find out.

  • A rate-limit refusal with no wait hint is not retried. A game on the arcade's sandboxed origin cannot read the server's Retry-After header — the browser does not expose it across origins — so if a refusal carries no wait time, treat it as "stop," not "retry immediately." Retrying blind is how one slow moment becomes a storm.

  • A 402 means the player cannot afford it. Re-read the balance, re-render, and do not resend.

7. Confirm it landed#

The answer to "did my purchase go through" is the order, read back by its id:

const order = await gem.me.orders.get(result.order_id);
// order.status tells you what happened; the grant is in the player's next snapshot

This is also the answer after a timeout: you got no result back, so list recent orders and look for the one you attempted, rather than resending the purchase.

Rewarding a player — still a recipe#

When your game gives a player something — "you earned 100 coins," a quest reward — reach for a recipe too, not a direct grant. A reward flow is the same client-authored economy as a shop under a friendlier name, and the same rule applies: if the value matters, the server has to be the one that awards it. Author a reward recipe in your config and execute it exactly as above.

The one thing you must not do next#

Everything here is on the dev channel on purpose. This shop is not safe to put in front of real players as-is, and the reason is not a bug you can fix by promoting it:

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.

Recipes are what make this shop honest: the server charges the cost. But a shop is only as safe as the flows around it, and promoting a config to the channel every player sees is a decision to make deliberately, not a step in a tutorial. Keep this on dev until you have decided the whole economy around it holds up.

Read this page as Markdown