GameSDK reference: ads, economy & tournaments
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
GameSDK reference: ads, economy & tournaments
This page is the reference for these GameSDK calls:
Area | Calls |
|---|---|
Ads |
|
Economy |
|
Tournaments |
|
Events |
|
For concepts and walkthroughs, see Ads and rewarded ads, In-game economy and Tournaments. For loading the SDK, GameSDK.on, and the core lifecycle calls, see GameSDK core reference.
How these calls behave
All of these calls send a message from your sandboxed game to the Cool GPT Games page, which calls the API for you. Your game never handles the player's credentials. Keep these rules in mind:
No call rejects. Every method here returns a Promise that resolves, with an error object or
nullwhen something goes wrong. You don't needtry/catch, but you do need to check the value.Every call settles. Economy and tournament calls resolve
nullif the page doesn't answer within 6 seconds.requestAdresolves{ error: "timeout" }after 60 seconds. - Message rate limit. The page accepts at most 30 SDK messages per second from your game, counting every SDK call. Extra messages are silently dropped, so the call then times out. - Sign-in comes from the site. "Signed in" means the player is signed in to Cool GPT Games in that browser. Your game can check withGameSDK.getPlayer()(see GameSDK core reference). - Only on Cool GPT Games. Outside the Cool GPT Games player, for example when you open yourindex.htmllocally, nobody answers. Economy and tournament calls resolvenullafter 6 seconds, andrequestAdresolves{ error: "timeout" }after 60 seconds. Guard for this in local development.
Ads
GameSDK.requestAd(placement)
GameSDK.requestAd(placement?: string): Promise<
{ rewarded: boolean; rewardId?: string } | { error: string; retryAfterSec?: number }
>Asks the page to show an ad over your game. The promise settles exactly once.
Parameters
Name | Type | Required | Notes |
|---|---|---|---|
| string | No |
|
Resolves
Value | When |
|---|---|
| A rewarded ad was watched to the end of its 5-second countdown, and the ad server confirmed the reward. |
| A rewarded ad was closed early or the ad server didn't confirm the reward, or a midroll finished (midrolls never reward). No |
| The placement's frequency cap blocked it (see Limits). |
| No ad tag is configured for that format. Midroll/interstitial today. |
| No ad was available |
| The visitor hasn't allowed ads (banner unanswered, rejected, or the consent tool couldn't load) |
| Midroll only: requested before |
| Another ad is still in progress. That earlier ad and its promise carry on unaffected. |
| The page didn't answer within 60 seconds |
| Network or other failure |
| The build is running in a creator preview, which never requests a real ad |
Events fired
Case | Events |
|---|---|
Ad shown |
|
|
|
|
|
|
|
Verifying a rewarded result. rewardId is recorded server-side when the ad network confirms the reward. Your own backend verifies it once with GET /v1/dev/games/:id/ad-rewards/:rewardId (an API key with the economy or analytics scope), which returns the receipt plus alreadyVerified so a replayed id is detectable. The same receipt is pushed as the ad.rewarded webhook event. See Ads and rewarded ads for the full flow and an example.
Failure behaviour
The promise never rejects, and it settles exactly once: with the ad's result, or with one error.
60-second timeout. If the page never answers (the message was dropped, or you're not running on Cool GPT Games), the promise resolves
{ error: "timeout" }andadError { reason: "timeout" }fires. If the page's answer arrives later anyway, itsadStarted/adComplete/adErrorevents still fire, but the promise has already settled.One ad at a time. Calling
requestAdwhile an earlier ad is in progress resolves the new call with{ error: "busy" }straight away. The earlier call isn't affected and still settles with its own result. (An ad that has been in progress for more than 90 seconds no longer blocks a new one.)
Sign-in: not required. Guests get ads and rewarded results the same way as signed-in players.
Limits
Frequency caps are enforced by the API before any ad is fetched. They're counted against the current play session; with no session (a rewarded ad from a menu) they're counted per viewer per game over a rolling 30-minute window.
Placement | Cap | Over the cap |
|---|---|---|
| 12 per session |
|
| at least 15 seconds apart |
|
| 4 per session |
|
| at least 3 minutes apart |
|
A request nobody reports an outcome for still counts against the cap. A request that ends in no_fill or an error doesn't.
Only the spacing rules produce a retryAfterSec. A used-up per-session allowance doesn't, because it doesn't reset until the next play session.
Ad requests and ad events are also limited to 60 per minute per play session (or per viewer per game when there's no session).
Example
async function watchForReward() {
button.disabled = true;
const r = await GameSDK.requestAd("rewarded"); // always settles, at most 60 s
button.disabled = false;
if (r.rewarded === true) grantBonus(r.rewardId); // pass rewardId to your server to verify
else if (r.error === "no_consent") toast("Allow ads to earn this reward.");
else if (r.error === "capped") {
if (r.retryAfterSec) toast("Another ad in " + r.retryAfterSec + "s.");
else button.remove(); // allowance used up for this session
}
}Pitfalls
Grant rewards on
rewarded === trueonly.adCompletealso fires for closed, unconfirmed and midroll ads.The platform doesn't pause your game. Pause on
adStartedand resume onadCompleteoradError, and make resuming safe to run twice. Note thatadError { reason: "busy" }fires while another ad is still showing, so don't resume onbusy. Disabling your ad button until the promise settles avoids it entirely.The
pauseandresumeevents are about tab visibility, not ads.The platform grants nothing for a rewarded ad. The reward is whatever your game gives.
Midroll is implemented but no interstitial ad tag is configured yet, so midroll requests resolve
{ error: "not_configured" }today. Never make progress depend on one.rewarded: truewithout arewardIdcan happen if the receipt couldn't be recorded. Treat the reward as client-only in that case, and don't hand a missing id to your backend.
Economy
Items are defined per game by the creator. See In-game economy. Prices are in coins, a virtual currency with no real-money value.
GameSDK.economy.listItems()
GameSDK.economy.listItems(): Promise<Item[] | null>
interface Item {
sku: string;
name: string;
description: string | null;
priceCoins: number; // integer ≥ 0
kind: "durable" | "consumable"; // durable = owned once; consumable = bought in units
}Returns this game's active store items.
Parameters: none.
Resolves: an array of Item. The array order isn't guaranteed, so sort it yourself.
Failure behaviour: resolves [] if the API returns an error, and null on a network error or after the 6-second timeout.
Sign-in: not required. Guests see the same list.
Example
const items = (await GameSDK.economy.listItems()) || [];
items.sort(function (a, b) { return a.priceCoins - b.priceCoins; });
items.forEach(function (it) { addRow(it.name, it.priceCoins + " coins", it.sku); });Pitfalls: inactive or deleted items aren't listed, but players may still hold them. Always combine this list with getEntitlements().
GameSDK.economy.purchase(sku, opts?)
GameSDK.economy.purchase(
sku: string,
opts?: { quantity?: number; idempotencyKey?: string },
): Promise<PurchaseResult | { error: string } | null>
type PurchaseResult =
| { ok: true; purchased: string; purchaseId: string; kind: ItemKind; quantity: number; coins: number }
| { ok: true; alreadyOwned: true; kind: "durable"; quantity: 1; coins: number }
| { ok: true; replayed: true; purchased: string; purchaseId: string; kind: ItemKind;
status: string; quantity: number; coins: number };
type ItemKind = "durable" | "consumable";Buys an item for the signed-in player with their coins. A durable is owned permanently. A consumable adds units the game spends with consume.
Parameters
Name | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | Must match an active item on this game. 1–64 characters. The value is converted with |
| number | No | Units to buy, consumables only: an integer 1–99, default 1. A durable accepts only 1. |
| string | No | 1–64 characters of letters, digits, |
Resolves
Value | Meaning |
|---|---|
| Bought. |
| Durable already owned. No charge. |
| The |
| The player is a guest. Nothing happened. |
| The balance is too low for the total. Nothing was charged. |
| A quantity other than 1 for a durable item |
| That key was already used for a different SKU |
| No active item with that SKU on this game, or the game wasn't found |
| More than 60 purchases per minute by this player |
| The SKU, quantity or key broke the constraints above |
| The player's sign-in is no longer valid |
| Other failure |
| Timeout (6 s), network error, or |
Events: fires purchase only for a successful answer (ok: true, including alreadyOwned and replayed), with that answer as the payload. Errors, null and the timeout never fire it. A successful answer that arrives after the 6-second timeout has already resolved your promise with null still fires the event, so listening for purchase catches late successes.
Idempotency:
A purchase is atomic: the sale is recorded, the entitlement granted, the player charged and the creator credited together, or not at all.
Repeat purchases of an owned durable are free no-ops that return
alreadyOwned. Two of them in flight at the same time can't both charge: at most one is charged and the other resolvesalreadyOwned(or fails on its own, for example withinsufficient_coins).Repeat purchases of a consumable are real purchases and charge again. There's no automatic protection, so pass an
idempotencyKeywhenever a retry is possible.An
idempotencyKeyis scoped to this player and game. The first call does the work; any repeat returns that same purchase withreplayed: true.
Sign-in: required. Guests get { error: "sign_in_required" }.
Limits: 60 purchases per minute per player. 99 units per consumable purchase.
Example
buyBtn.onclick = async function () {
buyBtn.disabled = true;
const key = "buy-" + Date.now() + "-" + Math.random().toString(36).slice(2);
const r = await GameSDK.economy.purchase("extra_life", { quantity: 5, idempotencyKey: key });
buyBtn.disabled = false;
if (r && r.ok) { setUnits("extra_life", r.quantity); setBalance(r.coins); return; }
if (r === null) { // unknown outcome: retry with the SAME key
const again = await GameSDK.economy.purchase("extra_life", { quantity: 5, idempotencyKey: key });
if (again && again.ok) setUnits("extra_life", again.quantity);
return;
}
toast(r.error === "insufficient_coins" ? "Not enough coins" :
r.error === "sign_in_required" ? "Sign in to buy" : "Purchase failed");
};Pitfalls
A
nullresult doesn't mean "not purchased". Retry with the sameidempotencyKey, or reconcile withgetEntitlements().Without a key, a retried consumable purchase charges twice.
Buying a consumable doesn't spend it. Call
consumewhen the player actually uses a unit.A creator can refund a sale within 30 days, which revokes the entitlement or reduces the units. Re-read
getEntitlements()on load rather than caching ownership forever.Coins can't be bought. Don't show a "buy coins" prompt.
GameSDK.economy.consume(sku, qty?)
GameSDK.economy.consume(sku: string, qty?: number): Promise<
{ ok: true; sku: string; consumed: number; remaining: number } | { error: string } | null
>Spends units of a consumable the signed-in player holds. Atomic, and never goes below zero.
Parameters
Name | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | An item on this game with |
| number | No | Units to spend. Default 1. The SDK floors it and raises anything below 1 to 1; the API rejects more than 1,000 with |
Resolves
Value | Meaning |
|---|---|
| Spent. |
| The player holds fewer than |
| The SKU is a durable |
| No item with that SKU on this game (including one you deleted) |
| The player is a guest |
| More than 60 consume calls per minute by this player |
| Other failure |
| Timeout (6 s) or network error |
Sign-in: required.
Limits: 60 per minute per player, counted separately from purchases. 1,000 units per call.
Example
async function useHint() {
const r = await GameSDK.economy.consume("hint_token", 1);
if (r && r.ok) { setUnits("hint_token", r.remaining); revealHint(); return true; }
if (r && r.error === "insufficient_quantity") openStore("hint_token");
return false;
}Pitfalls
Spend first, then grant the effect. A
nullresult is ambiguous, so don't give the effect away on an answer you didn't get; re-readgetEntitlements()instead.insufficient_quantityspends nothing, so it's safe to show the store and retry.
GameSDK.economy.getEntitlements()
GameSDK.economy.getEntitlements(): Promise<{
owned: string[];
entitlements?: Array<{ sku: string; kind: "durable" | "consumable"; quantity: number }>;
} | null>What the signed-in player holds in this game.
Parameters: none.
Resolves: { owned: ["skin_gold", …], entitlements: [{ sku, kind, quantity }, …] }.
ownedlists the SKUs the player holds right now: durables they own, and consumables with at least one unit left.entitlementsgives every SKU they've ever held, with its kind and current units. A durable hasquantity: 1; a consumable spent down to 0 stays here withquantity: 0while dropping out ofowned.Both include SKUs whose items have since been deactivated or deleted.
Failure behaviour: resolves { owned: [] } — with no entitlements key — for guests and when the API returns an error, and null on a network error or timeout.
{ owned: [] }doesn't prove the player holds nothing. It's also what you get for a guest or an API error. Don't remove unlocks based on an empty result. Only add unlocks based onowned.
Sign-in: needed for a meaningful result. Guests always get { owned: [] }.
Example
GameSDK.economy.getEntitlements().then(function (e) {
((e && e.owned) || []).forEach(unlock);
((e && e.entitlements) || []).forEach(function (t) {
if (t.kind === "consumable") setUnits(t.sku, t.quantity);
});
});Tournaments
Tournaments are created server-side by the creator. See Tournaments. Scores reach them automatically through GameSDK.submitScore() and GameSDK.replay.submit(). There's no tournament-specific score call.
The Tournament objects below share this shape:
interface Tournament {
id: string;
name: string;
description: string | null;
metric: "high_score" | "total_score";
status: "scheduled" | "live" | "ended" | "settled" | "canceled";
prizePoolCoins: number; // coins (virtual currency)
maxWinners: number;
startsAt: string; // ISO 8601
endsAt: string; // ISO 8601
}GameSDK.tournaments.list()
GameSDK.tournaments.list(): Promise<ListedTournament[] | null>
interface ListedTournament extends Tournament {
entries: number; // number of players entered
joined: boolean; // this player has an entry (joined or scored)
myRank: number | null; // this player's current rank; null until they've scored
}This game's tournaments:
scheduled, live and ended ones
settled ones for 7 days after settlement
up to 20, latest
endsAtfirstnever canceled ones
Status changes (scheduled → live, and settlement of tournaments past endsAt) happen in the background about every 30 seconds. Reading the list also applies any that are due.
Parameters: none.
Resolves: an array (possibly empty).
myRankuses the same ranking as the standings and settlement: higher score first, then whoever reached that score first. Players in an exact tie (same score reached at the same moment) share a rank, and the next rank is skipped (1, 2, 2, 4). See Tournaments.joinedistruefor any entry. A player who joined but hasn't scored hasjoined: trueandmyRank: null.
Failure behaviour: resolves [] if the API returns an error, and null on a network error or timeout.
Sign-in: not required. For guests, joined is false and myRank is null.
Example
const ts = (await GameSDK.tournaments.list()) || [];
ts.filter(function (t) { return t.status === "scheduled"; })
.forEach(function (t) { addUpcoming(t.name, new Date(t.startsAt)); });myRank always matches the player's rank in getStandings(). Use it when the player is outside the top 100 that getStandings() returns.
GameSDK.tournaments.getActive()
GameSDK.tournaments.getActive(): Promise<ListedTournament | null>A convenience wrapper over list(). It returns the first tournament with status === "live", which is the live one with the latest endsAt, or null.
Parameters: none.
Resolves: one ListedTournament, or null if none is live.
Failure behaviour: null on any failure or timeout. That's indistinguishable from "no live tournament".
Sign-in: not required. Guest behaviour is the same as for list().
Example
GameSDK.tournaments.getActive().then(function (t) {
if (t) showBanner(t.name + " ends " + new Date(t.endsAt).toLocaleString());
});GameSDK.tournaments.join(id)
GameSDK.tournaments.join(id: string): Promise<{ ok: true; joined: true } | null>Enters the signed-in player into a tournament with a score of 0 and no rank, so they appear on the board before scoring. Joining is optional. The first accepted score enters them anyway.
Parameters
Name | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | A tournament |
Resolves: { ok: true, joined: true }. That's also the result when the player had already joined.
Failure behaviour: resolves null for any of these:
the player is a guest
the tournament isn't open (
closed)the tournament doesn't exist
any other error, or a timeout
The specific reason isn't passed to the game.
Events: fires tournamentJoined with { ok: true, joined: true } only when the join succeeded. Failures and timeouts never fire it. A success that arrives after the 6-second timeout still fires the event.
Sign-in: required. Guests get null.
Example
joinBtn.onclick = async function () {
const r = await GameSDK.tournaments.join(currentTournament.id);
if (r && r.joined) joinBtn.textContent = "You're in!";
else toast("Sign in to join, or this tournament has closed.");
};Pitfall: joining alone never wins anything. A player who joined but never scored has no rank and gets no prize, however few players there are.
GameSDK.tournaments.getStandings(id)
GameSDK.tournaments.getStandings(id: string): Promise<{
tournament: Tournament & { game: { slug: string; title: string } };
standings: Standing[];
} | null>
interface Standing {
rank: number | null; // null for an entrant who hasn't scored
handle: string;
displayName: string | null;
avatarUrl: string | null;
score: number;
prizeCoins: number; // 0 until settled
}The full detail and the top 100 standings of any tournament, by id. Reading it settles the tournament straight away if its window has closed and the background job hasn't settled it yet.
Parameters
Name | Type | Required | Constraints |
|---|---|---|---|
| string | Yes | Any tournament id. It doesn't have to belong to this game. |
Resolves:
Standings are ordered by score, highest first, then by who reached that score first. Entrants who haven't scored come last, with
rank: null.rankis a competition rank: players in an exact tie share it and the next rank is skipped (1, 2, 2, 4). It's the same rankmyRankinlist()reports and settlement uses.Before settlement,
prizeCoinsis 0. After settlement,rankandprizeCoinsare the final stamped values. Tied winners show their equal split of the prize.
Failure behaviour: null if the tournament doesn't exist, on an API error, or on a timeout.
Sign-in: not required. The result is the same for everyone.
Example
const r = await GameSDK.tournaments.getStandings(t.id);
if (r) r.standings.slice(0, 10).forEach(function (s) {
addRow(s.rank ? "#" + s.rank : "–", s.displayName || s.handle, s.score,
s.prizeCoins ? s.prizeCoins + " coins" : "");
});Pitfalls
Only the top 100 are returned. A player ranked lower won't find themselves here, so use
myRankfromlist().Two rows can share a rank. Don't use
rankas a unique key or list index.
Events
Subscribe with GameSDK.on(name, callback). Callbacks that throw are ignored and don't affect other listeners.
Event | Payload | Fired by |
|---|---|---|
| none | An ad overlay appeared |
|
| An ad ended or was closed |
|
| No ad was shown for that request |
| The successful | A successful |
|
| A successful |
| none | The browser tab was hidden or shown. Not fired for ads. |