Documentation
Build a game economy in an afternoon

One SDK gives your game players, balances, rewards, an NFT marketplace and a real order book. This page is the complete v1 surface — every endpoint, every field, and the reasoning behind the parts that look unusual.

Quickstart

Everything below runs against your own game server. GameFi Rails is a server-to-server API: your backend already knows who the player is, so there is no wallet connect, no signature prompt, and no key material anywhere near the game client.

1 · Install

shell
npm install @gamefi-rails/sdk-js

2 · Instantiate the client

baseUrl points at the API (port 7300 in the sandbox). apiKey is optional: omit it and the request runs against the shared sandbox tenant, which is exactly what the public demo pages do.

game-server.ts@gamefi-rails/sdk-js
import { GameFiClient, GameFiError } from '@gamefi-rails/sdk-js';

const gamefi = new GameFiClient({
  baseUrl: 'http://localhost:7300',
  apiKey: process.env.GAMEFI_API_KEY,  // server-side only — never in a build
  retries: 2,                        // network + 5xx only; 4xx is terminal
});

3 · Create a player

You identify players by whatever id your game already uses — a Steam id, an account uuid, an email hash. The call is idempotent: the same externalPlayerId always returns the same playerId, so you can call it on every login without bookkeeping.

game-server.ts
const player = await gamefi.createPlayer('steam:12345');
// → { playerId: '9f1c…', tenantPlayerRef: '4b7e…' }

// Sandbox only: 100 USDC of play money so the player can trade.
await gamefi.faucet(player.playerId, 'USDC');

4 · Reward gameplay

Rewards are the mint path for your game token — server-side only, since calling this creates supply. Each call posts a ledger transaction against system:mint:DRAGON, so emissions are auditable rather than a counter somebody incremented.

game-server.ts
await gamefi.reward(player.playerId, 'boss-slain');
// → { earned: '25 DRAGON' }   ('dragon-slain' pays 5)

// Loot becomes a tradable asset the player owns outright
const loot = await gamefi.mintNft(player.playerId, 'swords', 'Ember Fang');
await gamefi.listNft(player.playerId, loot.assetId, '15');

5 · Place an order

The player now has a balance worth selling. Orders go on a real price-time-priority order book; the call returns as soon as funds are reserved and the command is durably queued, which is why it answers 202 Accepted with status: 'PENDING' rather than a fill.

game-server.ts
try {
  const order = await gamefi.placeOrder({
    playerId: player.playerId,
    pairId:   1,          // DRAGON/USDC
    side:     'Sell',
    orderType: 'Limit',
    price:    '2.40',     // whole USDC per whole DRAGON
    qty:      '10',       // whole DRAGON
  });
  // → { clientOrderId: '…', status: 'PENDING', reserved: '10000000 DRAGON (minor)' }
} catch (e) {
  if (e instanceof GameFiError && e.code === 'INSUFFICIENT_FUNDS') { /* … */ }
  throw e;
}

// Watch it fill. The stream replays history, so this is complete.
const stop = gamefi.events(({ pairId, event }) => {
  if (event.type === 'Trade') console.log(pairId, event.qty, event.price);
});
What the JS client covers today createPlayer, faucet, getBalances, placeOrder, studioSummary and events. Rewards, NFT minting and the marketplace are plain HTTP calls documented below — same auth, same error envelope.

Authentication

Every authenticated request carries a studio API key in the x-api-key header. The SDK adds it for you when you pass apiKey to the constructor.

http
POST /v1/players
x-api-key: gfr_9c1f0b7a4e2d…
content-type: application/json

Minting a key

Keys are created together with the tenant at POST /v1/tenants. The response is the only time the plaintext key exists outside your control: the server stores just sha256(key) in api_keys.keyHash and compares hashes on every request. There is no "show key again" endpoint because there is nothing left to show — a leaked database gives an attacker hashes, not credentials. Lose the key and you mint a new one.

response · 201
{
  "tenantId": "3f2a…",
  "apiKey": "gfr_9c1f0b7a4e2d…",
  "note": "store the key now — it is not shown again"
}

What a key resolves to

A valid key resolves to your tenantId, and that tenant scopes your players, your fee accounts, your listings and your studio metrics. A key that is present but unknown or revoked fails the request with UNAUTHORIZED (401). A request with no key at all falls back to the shared sandbox tenant — convenient for prototyping, but it means a silently dropped header looks like "it works" while writing into the wrong tenant. Assert the header in your own integration tests.

Never ship a studio key in a game client A key in a Unity, Unreal or browser build is extractable in minutes, and a studio key can create players, mint NFTs and read your entire economy. Keys belong on your game server; the client talks to your server, and your server talks to GameFi Rails. Player-scoped client sessions arrive with the embedded-wallet auth flow — until then, the key-less sandbox is the only client-safe mode, and it is for prototyping only.

Core concepts

Five ideas explain almost every surprising thing in the API. They are all downstream of one decision: this is accounting infrastructure that happens to serve games, not a game backend that happens to hold money.

Money is an integer, always

Every amount on the wire and in the database is an integer count of minor units — the smallest indivisible piece of the asset. USDC and DRAGON both use 6 decimals, so 1 USDC = 1_000_000 minor units. Floating point never touches a balance: 0.1 + 0.2 is a rounding curiosity in a chart and a lawsuit in a ledger. The SDK surfaces decimal strings ("2.40") at the boundary and converts to integers immediately, so your code never handles a float either.

The price wire format

Prices need more precision than an amount does, so they carry their own scale:

price encoding
// wire price = quote minor units per ONE WHOLE base unit, × 1e8
2.5 USDC/DRAGON
  → 2.5 × 1e6            // 2_500_000 quote minor per whole DRAGON2_500_000 × 1e8       // PRICE_SCALE
  = 250_000_000_000_000

// and the one derivation everything settles on:
quoteAmount = (price × qty) / (PRICE_SCALE × 10^baseDecimals)

You send price: "2.5" and the API does this conversion. It matters anyway, because it is the number you will see on the WebSocket stream and in the engine's events. Rounding on that division always goes against the taker — up when the taker pays quote, down when the taker receives it — and the sub-unit residual is posted to system:suspense:rounding rather than quietly vanishing.

Reservations: funds are held before the engine sees the order

POST /v1/orders does five things in a single Postgres transaction: validate the order against the pair's rules, compute the required reserve, insert the reservation row, post the ledger hold moving value from user:{id}:available:{sym} to user:{id}:hold:{sym}, and enqueue the NewOrder command in the transactional outbox. Then it commits.

The ordering is the whole point. The matching engine is fast and in-memory; it cannot ask "does this player still have the money?" at match time without becoming slow and stateful. So the money is already locked before the command can physically reach it — if the hold fails, no command is ever queued, and if the command is queued, the funds provably exist. A buy reserves worst-case notional plus taker fee in the quote asset; a sell reserves the base quantity. That reserve is why the response is 202 PENDING with a reserved figure instead of a fill.

The double-entry ledger

There is no balance column that code increments. Every movement of value — reward, deposit, reserve, trade, royalty, fee — is a transaction whose entries sum to zero per asset. A balance is the running total of entries against an account handle, and a continuous job asserts the zero-sum invariant, so a wrong balance cannot hide; it shows up as a broken book. Handles are readable and structural:

account handles
user:{playerId}:available:{symbol}   // spendable
user:{playerId}:hold:{symbol}        // reserved against an open order or listing
tenant:{tenantId}:fee:{symbol}       // your studio's fee revenue
system:mint:{symbol}                 // token issuance (negative = supply outstanding)
platform:fee:{symbol}

User accounts are credit-normal, so raw balances are negative and the API negates them for display. This is also why GET /v1/studio/economy can report emissions, holders, velocity and GMV at all — they are derived from the ledger, which is the source of truth rather than a cache of one.

NFTs are ledger assets

An NFT is not a parallel money system. Minting inserts an asset row with kind = 'NFT', decimals = 0 and a supply of exactly 1, and then posts an ordinary ledger transaction. Because of that, listing an item is just a hold (available → hold, which fails with INSUFFICIENT_FUNDS if the seller doesn't actually own it) and a sale is a single atomic multi-entry post: the buyer's USDC splits across seller proceeds, a 5% creator royalty, and a 2% market fee shared 50/50 between your studio and the platform, while the NFT moves whole in the same transaction. Royalties are enforced at settlement, not by convention — there is no code path where the item transfers and the royalty doesn't.

API reference

Base URL http://localhost:7300 in the sandbox. All request bodies are JSON; all responses are JSON. Fields marked * are required. Errors use the envelope { "error": CODE, "message": "…" } — see Errors.

Studio

POST /v1/tenants201

Create a studio tenant and mint its first API key. Open in the sandbox; production gates this behind the console.

FieldTypeNotes
name *string2–80 characters. Display name.
slug *string2–40 chars matching ^[a-z0-9-]+$. Must be unique.
{
  "tenantId": "3f2a1c8e-…",
  "apiKey": "gfr_9c1f0b7a4e2d…",
  "note": "store the key now — it is not shown again"
}
GET /v1/studio/summary200

Dashboard header numbers for the authenticated tenant. No request body.

{
  "tenantId": "3f2a1c8e-…",
  "feeBalances": { "USDC": "14.203000" },   // whole units, 6dp
  "players": 1284,
  "openReservations": 37              // reservations still HELD
}
GET /v1/studio/economy200

Economy health derived from the ledger: sources vs. sinks, holders, velocity, GMV, and any circuit breaker that has fired. No request body.

{
  "tokenEmissions": "48250.00",        // DRAGON minted via REWARD
  "feeRevenue": "14.20",               // your tenant fee accounts
  "tokenHolders": 612,
  "tradingVolumeQuote": "93110.50",     // settled TRADE volume in quote
  "marketplaceGmv": "7420.00",          // SOLD listings
  "riskEvents24h": 3,
  "haltedPairs": [ { "pairId": 1, "reason": "price_band" } ]
}

Players

POST /v1/players200

Register or fetch a player by your own external id. Idempotent — safe to call on every login.

FieldTypeNotes
externalPlayerId *string1–120 chars. Your id, e.g. steam:12345. Unique per tenant.
{
  "playerId": "9f1c2d70-…",        // use this on every other call
  "tenantPlayerRef": "4b7e…"          // per-tenant opaque alias
}

tenantPlayerRef is sha256(tenantId:playerId) truncated to 32 hex chars. It is safe to write into game saves, logs and analytics because two tenants holding refs for the same underlying player cannot join their datasets on it.

Orders

POST /v1/orders202

Reserve funds and submit an order to the matching engine. Returns once the reserve is committed and the command is durably queued — not once it fills.

FieldTypeNotes
playerId *uuidFrom POST /v1/players.
pairIdintDefault 1. Pair 1 and 2 are both DRAGON/USDC on separate books.
side *enumBuy | Sell
orderTypeenumLimit (default) | Market | PostOnly | Ioc | Fok
pricestringDecimal string, whole quote per whole base, e.g. "2.5". Required for everything except Market.
qty *stringDecimal string in whole base units, e.g. "10".
maxQuotestringRequired for market buys — the worst-case quote band to reserve.
{
  "clientOrderId": "c1a8…",          // correlates with every stream event
  "status": "PENDING",
  "reserved": "25075000 USDC (minor)"  // 25 USDC notional + 0.075 taker fee
}

A market buy has no price to compute a reserve from, so you must supply maxQuote yourself: it is the ceiling the player agrees to spend, and it is reserved in full up front. Anything unspent is released when the fill settles.

Pair rules (sandbox)

Orders are validated against the pair's limits at the edge using the exact same rules the Rust engine applies, so a rejection is deterministic and arrives as a VALIDATION error rather than an engine round-trip.

RulePair 1 & 2 (DRAGON/USDC)
minQty10 base minor units
qtyStep10 base minor units
priceStep1 000 000 (wire price units)
minNotional1 quote minor unit
takerFeeBps30 (0.30%)
makerFeeBps10 (0.10%)

Marketplace

POST /v1/nfts/mint200

Mint a game item as a ledger asset (decimals 0, supply 1) straight into the player's available balance. The minting player is recorded as the creator and earns the royalty on every later resale.

FieldTypeNotes
playerId *uuidReceives the item.
collection *stringSlug, 2–24 chars matching ^[a-z0-9-]+$, e.g. swords.
name *string1–60 chars, e.g. Ember Fang.
{
  "assetId": 318,
  "symbol": "NFT#swords:1a2b3c4d"
}
GET /v1/nfts/:playerId200

Every NFT the player holds, in available or in hold. listed: true means it is parked in hold behind an active listing.

{
  "nfts": [
    { "assetId": 318, "symbol": "NFT#swords:1a2b3c4d",
      "name": "Ember Fang", "listed": false }
  ]
}
POST /v1/market/list200

List an owned NFT for sale in USDC. The item moves into hold as part of the same transaction, so it cannot be listed twice or traded away behind the listing's back.

FieldTypeNotes
playerId *uuidThe seller. Must own the asset.
assetId *intFrom the mint response. Must be an NFT asset.
price *stringDecimal string in whole USDC, e.g. "15". Must be > 0.
{ "listingId": "7d40e1b2-…" }
GET /v1/market/listings200

Up to 50 active listings for the authenticated tenant, newest first. priceMinor is an integer string in quote minor units.

{
  "listings": [
    { "listingId": "7d40e1b2-…", "assetId": 318,
      "symbol": "NFT#swords:1a2b3c4d", "name": "Ember Fang",
      "priceMinor": "15000000", "sellerId": "9f1c2d70-…" }
  ]
}
POST /v1/market/buy200

Buy a listing. Settles atomically: payment splits across seller, creator royalty and fees while the item moves. No pre-hold is needed because the whole sale is one transaction.

FieldTypeNotes
playerId *uuidThe buyer. Cannot be the seller.
listingId *uuidMust still be ACTIVE — otherwise NOT_FOUND.
{
  "bought": "NFT#swords:1a2b3c4d",
  "paid": "15.00"
}

How a sale splits

LegShareGoes to
royalty500 bps (5%)The minting creator — only when the creator is not the seller, so self-resale churn earns nothing.
market fee200 bps (2%)Split 50/50 between tenant:{you}:fee:USDC and the platform.
proceedsremainderThe seller.

Rewards

POST /v1/rewards200

Mint game tokens for a gameplay achievement. This is a real ledger issuance from system:mint:DRAGON, which is why it shows up in tokenEmissions.

FieldTypeNotes
playerId *uuidCreated on the fly if unknown.
kind *enumdragon-slain → 5 DRAGON · boss-slain → 25 DRAGON
{ "earned": "25 DRAGON" }

A 3-second per-player cooldown is enforced server-side; a faster second call returns 429 RATE_LIMITED. It is deliberately crude — a client that can call "I killed a boss" can call it in a loop, so the first brake has to live on the server. The risk engine adds real behavioural scoring on top of it. Call this from your game server after your own authoritative combat resolution, never from the client.

Balances & faucet

GET /v1/balances/:playerId200

Every asset the player holds, split into spendable and reserved. Values are decimal strings at 6dp — hold is money locked behind open orders and listings, and it is not spendable until those resolve.

{
  "balances": {
    "USDC":   { "available": "74.925000", "hold": "25.075000" },
    "DRAGON": { "available": "25.000000", "hold": "0" }
  }
}
POST /v1/faucetsandbox

Credits 100 whole units of play money so a fresh player can trade immediately. Development only — it posts a DEPOSIT against the testnet hot wallet handle.

FieldTypeNotes
playerId *uuidCreated on the fly if unknown.
symbol *enumUSDC | DRAGON
{ "credited": "100 USDC" }

Admin

POST /v1/admin/unhaltoperator

Release a circuit breaker after review. The risk engine halts a pair; the edge enforces the halt by rejecting new orders with PAIR_HALTED. Clearing it is a deliberate human act, so there is no automatic expiry.

FieldTypeNotes
pairId *intThe halted pair.
{ "unhalted": 1 }
Sandbox-open, production-gated /v1/tenants and /v1/admin/unhalt are unauthenticated in the sandbox so the demo is self-serve. In production both sit behind the studio console's operator auth. Do not expose them through your own game server.

WebSocket stream

The matching engine never talks to end users. A gateway consumes the shard's event topic and fans it out as JSON on ws://{host}:7301 — no subscribe message, no auth handshake, no channels. Connect and you receive events.

client.ts
const stop = gamefi.events(({ pairId, event }) => {
  switch (event.type) {
    case 'OrderAccepted': /* resting on the book */ break;
    case 'Trade':         /* a fill — settlement follows */ break;
    case 'OrderCanceled': break;
    case 'OrderRejected': /* event.reason tells you why */ break;
  }
});
// later
stop();

Every frame is { pairId, event }. The client derives the ws URL from baseUrl by swapping the scheme and the port to 7301; override it with wsUrl if your deployment differs. It reconnects automatically after 1.5s on close.

Full replay on connect

The gateway sends its entire event history to each new client before any live frame. That is intentional: a subscriber can rebuild the complete order book from nothing, so a dropped connection is not a correctness problem, just a reconnect. It also means you should treat your local state as derived — reset and rebuild on every open rather than trying to patch a partial view back together.

Event types

typeMeaning
OrderAcceptedThe order passed engine validation. booked_qty is how much rests on the book (0 for a fully-crossing taker).
TradeA maker and a taker matched. Settlement posts to the ledger separately, keyed to seq.
OrderCanceledAn order left the book; canceled_qty is the unfilled remainder being released.
OrderRejectedThe engine refused the order. reason is one of DuplicateClientOrderId, UnknownPair, PairHalted, InvalidPriceStep, InvalidQtyStep, BelowMinNotional, InsufficientReservation, PostOnlyWouldCross, FokUnfillable, SelfTradePrevented, Expired, EngineOverload.

Trade fields

FieldTypeNotes
sequ64 (string)Per-pair sequence number. See the warning below.
trade_idu64 (string)Engine-assigned trade identifier.
taker_order_id / maker_order_idu64 (string)Engine order ids on each side.
taker_client_order_id / maker_client_order_iduuidThe clientOrderId you got back from POST /v1/orders.
taker_account_id / maker_account_iduuidPlayer ids.
taker_tenant_id / maker_tenant_iduuidTenant ids — the two sides may belong to different studios.
priceu64 (string)Wire price: quote minor per whole base × 1e8.
qtyu64 (string)Base minor units filled by this trade.
quote_amountu128 (string)Quote minor units exchanged, rounded against the taker.
taker_sideenumBuy | Sell — the aggressor's direction.
taker_fee_amountu128 (string)Taker fee in quote minor units.
maker_fee_amounti128 (string)Maker fee — signed, so a negative value is a maker rebate.
taker_remaining_qty / maker_remaining_qtyu64 (string)Unfilled remainder on each side after this trade.
maker_order_closedboolTrue when the maker order is now fully filled and off the book.

All 64/128-bit integers arrive as JSON strings, not numbers — they overflow IEEE-754 doubles. Parse them with BigInt, never Number.

seq is per pair, not per stream Each pair runs its own engine instance with its own counter starting at 1, and the gateway multiplexes every pair onto one socket. So seq: 42 appears once for pair 1 and again for pair 2, and the sequence you observe on the wire is not monotonic. Combined with full replay on reconnect — where you legitimately receive frames you have already processed — a consumer that dedups on seq alone will silently drop real events from other pairs and double-count on every reconnect. Always key on the tuple (pairId, seq).
dedup.ts
const seen = new Set<string>();

gamefi.events(({ pairId, event }) => {
  const key = `${pairId}:${event.seq}`;   // NOT event.seq alone
  if (seen.has(key)) return;
  seen.add(key);
  apply(pairId, event);
});

Errors

Failures return a JSON envelope with a stable machine-readable code. Branch on error; the message is for your logs, not your control flow.

response · 422
{ "error": "INSUFFICIENT_FUNDS", "message": "insufficient available balance" }
CodeHTTPWhen
VALIDATION400The request violates a rule: bad pair limits, non-positive price, buying your own listing, a market buy without maxQuote.
UNAUTHORIZED401x-api-key was supplied but is unknown or revoked.
FORBIDDEN403The key is valid but not permitted to perform this action.
NOT_FOUND404Unknown pair, unknown NFT, or a listing that is no longer ACTIVE.
CONFLICT409A uniqueness or state conflict — the resource moved under you.
INSUFFICIENT_FUNDS422The ledger hold failed. The player does not have the available balance (or does not own the NFT) being reserved.
PAIR_HALTED423A circuit breaker is open on that pair. Cleared via POST /v1/admin/unhalt after review.
RATE_LIMITED429The 3-second reward cooldown for that player has not elapsed.
INTERNAL500An unhandled server fault. Safe to retry.

Retry semantics

The SDK transport retries network failures and 5xx with exponential backoff — 250 ms, then 500 ms, two attempts by default, tunable with retries. 4xx responses are terminal and throw immediately as a GameFiError carrying code, message and status. That split is the useful one: a 422 will fail identically on the next attempt, while a dropped connection or a restarting node usually won't.

error handling
import { GameFiError } from '@gamefi-rails/sdk-js';

try {
  await gamefi.placeOrder({ playerId, side: 'Buy', price: '2.5', qty: '10' });
} catch (e) {
  if (!(e instanceof GameFiError)) throw e;
  switch (e.code) {
    case 'INSUFFICIENT_FUNDS': return showTopUp();
    case 'PAIR_HALTED':        return showMarketPaused();
    case 'VALIDATION':         return showBadOrder(e.message);
    default:                    throw e;   // already retried if retryable
  }
}
Malformed bodies Schema failures raised by request parsing currently surface as 500 INTERNAL rather than 400 VALIDATION, which means the SDK will retry them before giving up. Validate shapes on your side; don't rely on the status code to distinguish "I sent nonsense" from "the server is unwell".

Unity SDK

The Unity package speaks the same v1 API with the same typed errors. It hides RPCs, gas, private keys and transaction tracking, so your players never see a wallet popup.

Install

Window → Package Manager → + → Add package from git URL:

UPM git URL
https://github.com/<org>/gamefi-rails.git?path=packages/sdk-unity

Or copy the folder into Packages/com.gamefios.sdk. Unity 2021.3+; a sample scene lives in Samples~/Quickstart.

Usage

Every call is a coroutine taking an Action<T> success callback and an optional Action<GameFiError> for failures — drive them with StartCoroutine so nothing blocks the main thread.

DragonArena.csGameFiOS.Sdk
using GameFiOS;

public class Economy : MonoBehaviour {
  CreatePlayerResult _player;
  MintResult _loot;

  // Sandbox: no key. Production: your game SERVER holds the key.
  GameFiClient gamefi = new GameFiClient("http://localhost:7300");

  void Start() {
    StartCoroutine(gamefi.CreatePlayer("steam:12345", r => _player = r));
  }

  public void OnBossSlain() {
    StartCoroutine(gamefi.Reward(_player.playerId, "boss-slain",
      r  => Debug.Log(r.earned),
      err => Debug.LogWarning(err.code)));

    // Loot becomes a tradable asset, then goes on the market.
    StartCoroutine(gamefi.MintNft(_player.playerId, "swords", "Ember Fang",
      r => _loot = r));
    StartCoroutine(gamefi.ListNft(_player.playerId, _loot.assetId, "15", _ => {}));

    // Or cash the token out on the order book. DRAGON → USDC.
    StartCoroutine(gamefi.PlaceOrder(_player.playerId, "Sell", "2.4", "10",
      r => Debug.Log(r.clientOrderId)));
  }
}

Available methods: CreatePlayer, Reward, MintNft, ListNft, BuyNft, PlaceOrder and GetBalancesRaw. Amounts are decimal strings on the boundary here too — Unity's float has no business anywhere near a balance.

Never ship a studio API key in a build The GameFiClient(baseUrl, apiKey) overload exists for editor tooling and trusted server-side C#, not for shipping clients. Anything in a build is public: a studio key extracted from an APK can mint your token and read your whole economy. Put the key on your game server, have the client call your server, and use the key-less sandbox only for prototyping.
Ready to build?
Mint a tenant key, or watch the whole loop run end to end first.