Rising Tide Developers
SDKCore

rtClient.games

SDK methods for game lobbies - sugar over the GAME domain, plus the seats, matches and board state a generic product has no equivalent of.

A game lobby is a smart product in the GAME domain: one contract that hosts a series of matches. rtClient.smartProducts can read one; rtClient.games exists for the parts that have no generic equivalent: the seats of the match being formed, the matches, and one match's board.

A match is a ProductInstance created when it starts and owned by the lobby contract until the winner claims the trophy. Two seats buy in at the lobby's fee, poolPct of each buy-in stays in the contract as the kitty, and the rest settles to the frozen contributions exactly as a mint does.

Reads return SmartProductGame rather than SmartProduct - the domain narrowing is enforced by the route, so you do not cast.

Reading

const res = await rtClient.games.list(params);            // PaginatedResponse<SmartProductGame>
const { data } = await rtClient.games.getById(id);         // gameLobbyDetails carries the seats and the feeLegs
const matches = await rtClient.games.getMatches(id, { page, limit });
const { data: match } = await rtClient.games.getMatch(id, matchIndex);   // GameMatchState

getMatch is what a board draws itself from: moves (the log, in turn order), board (one entry per square, the occupying player's address or null), activePlayerNumber (the seat to move, or null once the match has ended), status, winnerAddress, the kitty and its currency, and the participants with their results and payouts.

getContributions(id) returns the frozen Contribution[] - who gets paid from every buy-in, in the positions baked into the contract at deploy. Their percentages sum to 100 - poolPct.

Authoring

createDraft(input) takes a CreateGameLobbyDraftRequest and returns the product with its gameLobbyDetails. The game (tic-tac-toe or connect-four), the pool share and the buy-in travel in the body; the composition comes from the smart-lobby recipe, so a client never spells out a capability id:

await rtClient.games.createDraft({
  contractAddress, contractName, ownerAddress, sourceAppId,
  title: 'Alpha Arena',
  game: 'tic-tac-toe',
  poolPct: 50,
  minFeeStx: '1000000',
  minFeeSbtc: '1000',
  recipe: { name: 'smart-lobby', items: true, maxItemsPerMatch: 3 },
});

items: true composes the bomb - the tic-tac-boom mode. updateDraft(id, patch) and deleteDraft(id) work on the lobby while it is still a draft.

Playing

Every move is a transaction the player signs against the lobby contract, sent as the game-action command. rtClient.commands.submit resolves the session - a Privy wallet signs through the protocol, a connected wallet signs in its own popup - and registers the pending transaction on both paths. The result is acceptance and a txId, never the entity: read the board back with getMatch once the chain event lands.

await rtClient.commands.submit({
  kind: 'game-action',
  contractAddress, contractName,
  ownerAddress: me,
  action: 'join', currency: 'STX', amount: 1_000_000,   // the buy-in, in microSTX
  feeLegs: data.gameLobbyDetails.feeLegs,               // who a share of it goes to
  network: 'testnet',
  auth,
});
// then: { action: 'leave', currency, poolShare } before the match starts, { action: 'start' }, { action: 'move', input: 4 },
// { action: 'claim', matchIndex: 1 } or { action: 'claim-draw', matchIndex: 1 },
// and on a lobby that composes the bomb: { action: 'use-bomb', square: 4, currency, amount }

A join and a bomb carry an exact post-condition for what the signer sends in the named currency. That is the face amount for anyone who is not a creator of the lobby - but the contract never transfers a principal to itself, so a host seated at their own table sends only the pot share, and a condition for the full buy-in would abort the call. Pass the lobby's feeLegs (from getById) and the command states the right number; signerSpend(amount, feeLegs, signer) is the same arithmetic on its own. A lobby may price its bomb at 0: pass amount: 0, and the bomb carries an empty deny list, so nothing may leave the signer but gas. A seat is never priced at 0.

leave gives a seat up before the match starts. The contract returns the seat's recorded pool share - the contribution legs stay paid - and the command states that refund as an exact condition on the contract. On the connected-wallet path pass currency and poolShare (the seat's recorded pool share, in base units); the Privy path reads the seat server-side and ignores them.

A claim moves money the other way, and it broadcasts in deny mode like everything else: the condition sits on the CONTRACT principal, not the signer. Pass currency and payout - what you expect to collect, which is the kitty for a win and one seat's share of it for a draw - and the command asserts willSendGte(payout). A floor, never an exact amount: a kitty grows when someone uses an item, so a read that trails the chain is always low and an exact condition would abort a claim that is perfectly good. Omit them and the floor is zero, which still keeps the wallet from warning the signer that any of their assets may move. Use getMatch to refuse an illegal move before paying gas - the contract stays authoritative.

The bomb is a spectator item: use-bomb is refused on-chain for either seated player, so only a signed-in viewer who is not at the table may throw it, at any occupied square while the match is live. gameLobbyDetails.itemAudience ('spectator') declares who an item is for; gate the affordance on it rather than hard-coding the bomb.

The trophy

A decided match has a picture: the final board, the players, the pot. It is the match instance's product image, drawn by the lobby's style set when the match is decided, so a draw has one too. Read it with the instanceId from a match row:

const { data: rows } = await rtClient.games.getMatches(id);
const { data: images } = await rtClient.productImages.list(
  'product_instance',
  (rows?.items ?? []).map((m) => m.instanceId),
);

To draw a trophy without a match - a showcase, a style set under review - send a sample match to rtClient.gameStyles.generateLivePreview. The protocol replays it under the game's rules and draws it with the same content builder a settled match uses, so a sample that is not a legal game is refused rather than drawn:

import type { GameTrophySample } from '@rising-tide/sdk';

const sample: GameTrophySample = {
  game: 'tic-tac-toe',
  lobbyTitle: 'Friday Table',
  matchNumber: 3,
  players: [{ name: 'Ada' }, { name: 'Bo' }],
  inputs: [0, 3, 1, 4, 2],          // seat one takes the top row
  bombs: [{ square: 3, turn: 2 }],  // a spectator clears seat two's first mark
  potSats: '1250',
  settledAt: '2026-09-21T02:30:00Z',
};
const res = await rtClient.gameStyles.generateLivePreview(styleSetId, sample);
const svg = res.data?.data.preview.svg;

The model and what the lobby guarantees are on Game Lobbies; the routes are on the Games API page.

A player's record

const { data: record } = await rtClient.games.getRecord(address);   // GameRecord

Matches played, won, drawn, lost and hosted, plus the prizes claimed in each currency, projected from the event stream. An address that never played answers with zeros rather than a 404.

The record counts every game the address played. Pass a game for that game alone, counted from the same finished matches the leaderboard ranks:

const { data: ticTacToe } = await rtClient.games.getRecord(address, { game: "tic-tac-toe" });

A game's leaderboard

const { data: board } = await rtClient.games.getLeaderboard("tic-tac-toe", { limit: 20 });   // GameLeaderboard

Every address that finished a match of that game, ranked by wins, then draws, then fewest losses. Tied lines share a rank and the next rank skips, and totalPlayers counts every ranked address, not just the page. Rows carry addresses only: resolve names and pictures for the whole page in one rtClient.users.batchLookupUsers call.