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); // GameMatchStategetMatch 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); // GameRecordMatches 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 }); // GameLeaderboardEvery 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.