Skip to main content

Tutorial: Reigning Cats

What you'll build​

Reigning Cats is a small arcade game built with Phaser and Vite. It looks like a toy, but the source is wired up the way a production messaging game should be, and it exercises the SDK surface a real game needs with the patterns we recommend.

This tutorial walks the codebase in the order a user encounters each feature. By the end you'll have:

  • Run the game locally against the SDK's mock host
  • Read every SDK call site in context
  • Built and deployed the game to the Jest platform

The game exercises:

  • Player identity: guest play, registered-player branch, and a prompt cooldown so a decline is honored
  • Custom registration: a full-screen ask in the game's own art, shown after the first round, driving the platform overlay with a custom SMS message
  • Player data: high score, name, games played, all persisted across sessions
  • Entry payload: difficulty, notification attribution, referral attribution, onboarding name handoff
  • Retention notifications: a personalized D1 to D7 series with images, titles, and cancellation on return
  • App lifecycle: pause and mute when hidden, save the run in progress when the platform announces an exit
  • Purchases: extra_life and slow_down products, with crash-safe grant-before-confirm, startup recovery, and sandbox flagging
  • Subscriptions: a membership granting a 2× score, with free trials, introductory offers, and a win-back discount on cancel
  • Referrals: share-to-invite with a personalized preview image and conversion counting
  • Social: profile avatars, deterministic bot avatars on a leaderboard, and a custom screenshot provider
  • Loading screen: progress reported during Phaser asset preload, then the standardized readiness signal

A few documented APIs are deliberately not covered. Three need infrastructure a single sample game doesn't have: redirectToTeamGame needs a second game on the same team, getPlayerSigned needs a backend to verify the token, and shareReferralLink's onboardingSlug needs a separate onboarding game. The fourth is a choice: the game runs registration through showRegistrationOverlay() and so never calls login(), the one-line alternative that opens the platform's own popup. Each of those pages documents the API on its own.

Run it locally​

Clone the repo and start the Vite dev server:

git clone https://github.com/jest-com/reigning-cats
cd reigning-cats
npm install
npm run dev

Vite opens http://localhost:3000 automatically. The game loads, you can play it, and SDK calls are answered by the SDK's built-in mock mode, the same mode you use during day-to-day development. Open the platform debug menu via the JestSDK button in the top-right to see player state, entry payload, and the call log.

For details on what mock mode does and doesn't cover, see Testing locally with mocks.

How the SDK is loaded​

The Jest SDK is a single script you load before your game runs. In index.html:

<script src="https://cdn.jest.com/sdk/latest/jestsdk.js"></script>

This exposes a global JestSDK object. Reigning Cats installs @jest-com/types and lists it in tsconfig.json, so TypeScript knows the surface.

Call JestSDK.init() before any other SDK method, and don't construct your game until it resolves. From src/main.ts:

JestSDK.init({ autoLoginReminders: false }).then(() => {
const game = new Phaser.Game({
type: Phaser.AUTO,
/* ... */
scene: [GameScene],
});

// Lazy-load the game-over scene into its own chunk.
void import("./scenes/GameOverScene").then(({ GameOverScene }) => {
game.scene.add("GameOverScene", GameOverScene);
});
});

The game boots with only GameScene and code-splits GameOverScene into its own chunk, so the bytes for the end-of-round screen don't block the first paint.

autoLoginReminders controls the platform's automatic registration prompts for guests. It defaults to on. Reigning Cats turns it off, because it runs its own registration screen on its own schedule. With both on, a guest gets two asks that know nothing about each other. The platform's reminder can't see the round the player just finished, or the cooldown the game is keeping.

Turning it off is a commitment. Nothing else will nudge that guest, so the timing is yours to get right. Leave the default on if your game asks rarely, or never. See HTML5 SDK / SDK initialization for the full set of options.

Reporting load progress​

Reigning Cats sets the game's loading-screen mode to Manual in the Developer Console, which means the platform shows its branded overlay and waits for the game to report progress.

The pattern lives in src/scenes/GameScene.ts:

preload(): void {
this.load.on("progress", (value: number) => {
JestSDK.setLoadingProgress(Math.round(value * 99));
});
/* ...load images, audio... */
}

create(): void {
/* ...scene setup... */
JestSDK.markGameLoaded();
}

Three things to notice:

  • The Phaser progress event reports 0..1, and the SDK expects 0..100. The game caps the preload progress at 99 so the overlay doesn't dismiss until the scene has actually finished setting up in create().
  • The platform exits the user to home if no progress update arrives for 15 seconds. Each JestSDK.setLoadingProgress call resets that timer, so long loads are fine as long as you keep reporting.
  • The last call is markGameLoaded() rather than setLoadingProgress(100). In Manual mode it does both jobs. It dismisses the overlay, unless the game already drove progress to 100 itself. It also reports the load-complete milestone. For games in the Jest Fund, that milestone is how the platform analyzes the traffic it sends you. Call it when the player can start interacting. Don't wait for optional or user-gated downloads that happen after init, because that time would inflate the measured load with the player's own reaction time. Stream those assets in the background instead. It works in any loading-screen mode, and calls after the first are no-ops.

For the full API and the auto / manual / off mode trade-offs, see Loading screen.

Identifying the user​

Users enter as guests by default. Each user has a unique playerId per game. That identifier remains the same if the user later registers, so any data you've already saved carries over. Guest sessions can be lost when the user closes the browser, so converting guests to registered users is one of the most important steps in the funnel.

The interesting logic is how the game decides when to prompt registration. From src/scenes/GameOverScene.ts:

private maybePromptRegistration(): void {
const lastPromptGame =
(JestSDK.data.get("lastRegPromptGame") as number) ?? 0;
const isFirstPrompt = lastPromptGame === 0;
const gamesSincePrompt = this.gamesPlayed - lastPromptGame;

if (!isFirstPrompt && gamesSincePrompt < REG_PROMPT_COOLDOWN_GAMES) {
return;
}

JestSDK.data.set("lastRegPromptGame", this.gamesPlayed);
this.showRegistrationScreen();
}

The two rules are:

  1. Wait for a meaningful moment. The game asks after the player's first completed round, and not before. That is the first time they have a score they might not want to lose. A registration dialog before the player has tasted the game just produces churn.
  2. Cool down between prompts. Track when you last asked. If the player declined, don't ask again for several rounds. This game carries that alone, since it opted out of the platform's own reminders via autoLoginReminders. If you leave those on instead, treat your in-context ask as a layer on top of them and ask less often.

lastRegPromptGame is what makes rule 2 work, and it's the key the game deletes once the player registers.

For the full player API, see Player.

A registration screen in your own art​

There are two ways to run registration. JestSDK.login() opens the platform's built-in popup, and takes one line. JestSDK.showRegistrationOverlay() lets the game draw the ask itself, which is what Reigning Cats does. The cat holding a phone, the copy, and both buttons are the game's own pixel art, not platform UI.

Two decisions shaped it.

It's a screen, not a card. Registration is the only thing being asked at that moment, so it gets the whole viewport. It is a full-screen overlay above the canvas, covering the game-over screen underneath. A card tucked in beside a shop and a leaderboard competes with them and loses. The player picks one of the two buttons and the game moves on.

It's the only registration surface. Earlier drafts of this game also showed a save-progress panel on the start screen. Two asks in the same session is one too many, and the start screen is the worst place for it, because the player is trying to start playing. One well-timed ask beats two mediocre ones.

showRegistrationOverlay() returns two actions, and the game wires them to its own buttons:

const { loginButtonAction, closeButtonAction } =
JestSDK.showRegistrationOverlay({
theme: "dark",
message: "Let me into Reigning Cats! {{registrationCode}} is my code.",
entryPayload: { reason: "save_first_score", score: this.finalScore },
onClose: hide,
});

registerBtn.onclick = loginButtonAction;
laterBtn.onclick = () => {
hide();
closeButtonAction();
};

The message is the SMS body pre-filled for the player. It must contain {{registrationCode}} exactly once with whitespace or punctuation around it, and stay within 140 characters once the code is substituted in. The game's message comes to 45.

140 is the only length the SDK rejects you for. Non-ASCII is rewritten rather than refused. A message of 70 characters or fewer keeps its non-ASCII intact. Anything longer has accents folded to their base letters, and any other non-ASCII stripped, before it is sent. So café becomes cafe rather than caf. It is still a silent edit to copy you wrote, which is why the game sticks to plain ASCII.

The entryPayload comes back through getEntryPayload() when the player returns from the SMS flow, so you can tell which ask converted them.

Four things to keep in mind:

  • Guard the call. showRegistrationOverlay() throws if the player is already registered, so the game only reaches it in the guest branch.
  • The platform still draws a layer of its own. It renders the required legal text and its own close button on top of your UI. You must not hide, recreate, or obstruct either, and you must not interfere with the player skipping registration.
  • Dismiss your own UI without waiting for the callback. onClose fires on either dismissal route, the platform's own close button or your closeButtonAction(), but only once the platform confirms it. So the game's "not now" button does two things. It hides the screen immediately, then calls closeButtonAction(). Without the first, the screen would sit there through the round trip. onClose still earns its place as the handler for the dismissal your game didn't initiate.
  • Assign the handlers, don't add them. The player reaches this screen again every few rounds if they keep declining. onclick = replaces. addEventListener would stack a handler per round and fire the flow several times on one tap.

For the compliance rules and the built-in popup this game doesn't use, see Platform login.

Saving progress​

The platform key-value store is exposed at JestSDK.data. Reigning Cats keeps four keys:

KeyPurpose
playerNameCustom name, or fallback to platform username for registered players
highScoreBest score seen so far
gamesPlayedCounter, used for the registration-prompt cooldown
lastRegPromptGameLast game number where we showed the registration dialog

Writes go out as they are made. set sends its update immediately, unless an earlier update is still unacknowledged, in which case queued writes coalesce into the next message. At game over the scene needs two keys at once. It takes one getAll() snapshot and writes both back with the object form of set, so that's one read and one update:

const stored = JestSDK.data.getAll();
const prevHighScore = (stored.highScore as number) ?? 0;
const isNewHighScore = this.score > prevHighScore;
const gamesPlayed = ((stored.gamesPlayed as number) ?? 0) + 1;

JestSDK.data.set({
gamesPlayed,
highScore: isNewHighScore ? this.score : prevHighScore,
});

await JestSDK.data.flush();

set(partial) shallow-merges, so it only touches the keys you name. The single-key form set(key, value) is still there for one-off writes.

There is also delete(key), for state that has outlived its purpose. Once a player registers, the guest prompt cooldown is meaningless, so GameScene clears it:

if (player.registered) {
unscheduleRetentionSeries();
JestSDK.data.delete("lastRegPromptGame");
}

delete clears the value rather than removing the key outright, so a later get returns undefined. Handle that the same way you handle a key that was never written.

flush() returns a Promise<void> that resolves when the platform acknowledges the write. Awaiting it holds the scene transition until that acknowledgement arrives. It cannot keep a closing tab open, so treat it as confirmation rather than insurance.

Player data is written from the client and capped at 1 MB per game. Don't store anything sensitive or anything you need strong server-side guarantees on. For that, fetch a signed player payload and have your backend hold the data. The platform also doesn't track schema versions for you, so if you ever change the shape of stored values, plan to handle the upgrade in your own code.

Reading the entry payload​

The entry payload is a JSON object delivered to the game on launch. It's how the game learns why the player arrived. Reigning Cats reads four fields:

  • difficulty: "hard": shrinks the basket and makes the game harder. From GameScene.ts:
    if (JestSDK.getEntryPayload().difficulty === "hard") {
    this.basket.setScale(0.5);
    }
  • notification_template: set on every retention notification the game schedules. When the player taps a notification and the game reads this on re-entry, we know exactly which message brought them home, and we skip the name prompt to deliver them straight into a round.
  • referrer_name: set on links shared via the share-to-invite flow. We show a brief "X invited you!" toast.
  • customName: handed off by an onboarding game that collected a name before the player arrived. The start screen uses it to seed the player's name without asking again.

The name the game shows isn't read from a single source. setupStartScreen() resolves it through a priority chain, preferring the most authoritative value available:

const resolvedName =
player.username ?? // registered players: the platform username wins
customNameFromOnboarding ?? // else a name an onboarding game collected
(JestSDK.data.get("playerName") as string | undefined) ?? // else a saved name
null; // else fall through to prompting the player

You can simulate any entry payload locally by appending a URL parameter:

open "http://localhost:3000/?entryPayload=%7B%22difficulty%22%3A%22hard%22%7D"

That's a URL-encoded {"difficulty":"hard"}. The Simulator and the production platform deliver the payload through the same channel.

For the full API, see Entry payload.

Bringing players back: retention notifications​

src/retention.ts defines the retention series as a data table, one message for every day from D1 to D7. scheduleRetentionSeries(...) runs from GameOverScene after every round (registered players only, since guests can't receive notifications), and unscheduleRetentionSeries() runs from GameScene when the player returns.

IdentifierWhenPriorityAssetTitle
retention_d11 dayhighscore_defendYour record is under threat
retention_d22 dayshighchallengers_closingChallengers are closing in
retention_d33 daysmediumcats_miss_youYour basket is gathering dust
retention_d44 daysmediumscore_defendBeat your best
retention_d55 daysmediumchallengers_closingClimb back up
retention_d66 dayslowcats_miss_youA week is a long time
retention_d77 dayslowscore_defendOne more run?

Five patterns are worth calling out:

One message per day, D1 through D7. The review checklist treats a series that skips days as a problem, so the table covers every day rather than a sparse D1/D3/D7. Seven days is also the hard ceiling: scheduledInDays must be between 0 and 7, where 0 means later today.

Images and titles. Each entry sets an assetReference and a title. The reference is a string you register in the game's Image Library in the Developer Console, and it has to pass moderation before it can be scheduled. An unapproved or archived reference falls back to your game's hero image. The source art for these three lives in notification-assets/ in the repo, deliberately outside public/ so it isn't bundled into the build.

Fuzzy scheduling throughout. Every entry uses scheduledInDays, never an exact time, so the platform picks the best delivery moment per player from their messaging behavior. That beats a clock time you guessed at, and it's what the notifications guide recommends for anything relevant at any point in the day.

The alternative is scheduledAt, which takes a Date. Reach for it only when the moment itself is the point, such as a tournament closing or an energy refill completing. A generic "come back tonight" is not that, so this game has no use for it. If you do need it, pass exactly one of scheduledAt or scheduledInDays, since setting both or neither is rejected.

Cancel on return. When the player re-enters the game, unscheduleRetentionSeries() clears any pending messages so the player isn't pinged about a session they just played. From GameScene.ts:

if (player.registered) {
unscheduleRetentionSeries();
}

This pairs with re-scheduling on every game-over: the latest score travels into the messages, and stale messages from a prior session are cancelled before they land. The retention.ts module documents this as the "rolling notifications" pattern.

Tag for attribution. Every scheduled notification carries an entryPayload with notification_template and notification_offset:

JestSDK.notifications.scheduleNotification({
identifier: n.identifier,
scheduledInDays: n.scheduledInDays,
priority: n.priority,
assetReference: n.assetReference,
title: n.title,
body: n.body(context),
ctaText: n.ctaText,
entryPayload: {
notification_template: n.template,
notification_offset: `D${n.scheduledInDays}`,
},
});

That's how we close the loop with the entry-payload reader from the previous section. When a player taps a notification, the game knows which message and which day brought them back, which is perfect for measuring retention copy and A/B testing variants. GameScene reads notification_template on entry and skips the name prompt to drop the player straight into a round.

For the strategy behind the schedule, see Notifications best practices, which covers the rolling D0 to D7 approach. For the API itself, including the character limits on title, body, and ctaText, see Notifications.

Pausing, resuming, and saving on exit​

The platform tells the game when it is hidden, shown again, and when the player is on their way out. Reigning Cats splits the three hooks by what they need.

Visibility is a whole-game concern, so src/main.ts wires it once against the Phaser.Game instance rather than per scene. It tracks what it paused, so it never resumes a scene the game had paused for its own reasons:

let pausedByHide: string[] = [];

JestSDK.lifecycle.onHide(() => {
pausedByHide = game.scene.getScenes(true).map((scene) => scene.scene.key);
for (const key of pausedByHide) {
game.scene.pause(key);
}
game.sound.pauseAll();
});

JestSDK.lifecycle.onShow(() => {
for (const key of pausedByHide) {
game.scene.resume(key);
}
pausedByHide = [];
game.sound.resumeAll();
});

Phaser already sleeps its own loop when the document hides. These hooks are what let the game make the decisions the engine won't: silencing music, stopping spawn timers, and holding a countdown.

onExitRequested is different. It lives in GameScene because it needs the live score, and it unsubscribes on scene shutdown so a restart can't stack handlers:

const offExitRequested = JestSDK.lifecycle.onExitRequested(async () => {
if (this.score > ((JestSDK.data.get("highScore") as number) ?? 0)) {
JestSDK.data.set("highScore", this.score);
await JestSDK.data.flush();
}
});

this.events.once("shutdown", () => {
this.scale.off("resize", this.handleResize, this);
offExitRequested();
});

This is an opportunity to save, not a shutdown notice, and the distinction matters. The hook runs when the exit flow begins, usually as the confirmation dialog appears and always before the player has answered it. The player can still choose to stay, and frequently does. So the listener saves and tears nothing down. Don't release resources or stop the game loop here, or you'll gut a session that carries on. It also runs again every time the player asks to leave, backs out, and asks again, so keep it idempotent and expect to save more than once.

What the listener can't do is cancel the exit or hold the app open past the player's confirmation. What it gets is the interval between the request and that answer, which is ordinary runtime rather than a teardown window. Start the save when the event runs.

Not every departure produces this event. Closing the tab, quitting the browser, or an OS shutdown may not, so treat it as a good opportunity rather than a guarantee. Every subscribe call returns its own unsubscribe function. Listeners may run in any order, and may be async.

You can trigger this locally. The debug menu's App Lifecycle section has a Request Exit button. For the full contract, see App lifecycle.

Running a shop​

Reigning Cats sells two consumables: extra_life (adds a life) and slow_down (halves cat speed). Both are configured in the Developer Console and priced in USD. The game lists them dynamically and renders each price from the price and currency fields rather than hardcoding anything. See Displaying prices for formatting guidance.

The full checkout → grant → confirm lifecycle is inlined in GameScene.ts:

private async buyProduct(sku: string): Promise<void> {
try {
const result = await JestSDK.payments.beginPurchase({ productSku: sku });

if (result.result === "cancel") return;
if (result.result === "error") {
console.error("Purchase failed:", result.error);
return;
}

this.grantProduct(result.purchase.productSku, result.purchase.sandbox);

await JestSDK.payments.completePurchase({
purchaseToken: result.purchase.purchaseToken,
});
} catch (err) {
console.error("Purchase error:", err);
}
}

beginPurchase and completePurchase can also throw on transient failures (e.g. a timeout), so the whole flow is wrapped in try/catch. A thrown error leaves the purchase incomplete and recoverable on the next startup, the same safe state as a returned internal_error.

completePurchase can also return { result: "error", error: "internal_error" | "invalid_token" }. A transient internal_error looks identical to success unless you check it. In that case the purchase stays incomplete and is reconciled on the next startup. invalid_token means don't retry with the same token. See Recover incomplete purchases for the full state machine.

Two important orderings:

Grant before confirm. If the game crashes between checkout and confirmation, the purchase remains incomplete and the platform will return it from getIncompletePurchases() until you confirm it. That's the recoverable state. If you reverse the order and confirm first, a crash before granting leaves the purchase confirmed but never delivered to the player, and getIncompletePurchases() will not return it again.

Recover on every startup. Also from GameScene.ts:

private async recoverIncompletePurchases(): Promise<void> {
try {
let hasMore = true;
while (hasMore) {
const result = await JestSDK.payments.getIncompletePurchases();

for (const purchase of result.purchases) {
this.grantProduct(purchase.productSku, purchase.sandbox);
await JestSDK.payments.completePurchase({
purchaseToken: purchase.purchaseToken,
});
}

hasMore = result.hasMore;
}
} catch (err) {
console.error("Failed to recover purchases:", err);
}
}

The response is paginated, so we loop until hasMore is false. The same grantProduct(sku, sandbox) method handles both live purchases and recovered ones. Make sure your grant logic is idempotent so a recovered purchase isn't applied twice. If you have a backend, the purchaseToken is the natural idempotency key to store.

Recognize test purchases. Purchases made by a sandbox user, or driven from the Developer Console Simulator, carry sandbox: true on the returned PurchaseData and inside the signed token. They show the real configured price in your UI but record zero credits, so the game should grant the item and keep the purchase out of anything revenue-shaped.

Note that both call sites above forward purchase.sandbox into grantProduct, the live checkout and the recovery loop alike. That second one is easy to miss and matters just as much. An incomplete sandbox purchase recovered on a later startup is still a test purchase. Drop the flag there and it quietly starts looking real. grantProduct passes it through to the shop status line, which shows a TEST PURCHASE marker. In a game with a backend, the same flag on the signed payload is what stops test traffic reaching revenue reporting. See Recognize sandbox purchases.

The patterns above are sufficient for a client-only sample. For production we strongly recommend a backend: send purchaseSigned to your server, verify the HS256 JWS using your game's shared secret, and grant the item server-side before confirming. See Payments for the verification examples and the security rationale.

Selling a membership​

Alongside one-off products, Reigning Cats offers a membership subscription. Active members score 2× per cat caught. Subscriptions are configured per game in the Developer Console and billed in USD on a recurring cadence.

The mental model is different from products: there's no complete step. The platform owns the billing lifecycle, including renewals, expiries, and cancellations. Your game's only job is to read the current entitlement on every startup and unlock features accordingly. From GameScene.ts:

const { subscriptions } = await JestSDK.payments.getSubscriptions();

// Empty for guests or when none are configured — keep the UI hidden.
if (subscriptions.length === 0) return;

this.isPremium = subscriptions.some((s) => s.status === "active");

Each entry carries the offer (displayName, price, currency, billingPeriod) plus its status. It also carries whatever promotion the player is currently eligible for, and the button label has to reflect that rather than always showing the list price:

private formatOffer(sub: SubscriptionData): string {
const phases: string[] = [];

if (sub.trialEligible) {
phases.push("Free trial");
}
if (sub.introOffer) {
const intro = this.formatPrice({ ...sub, price: sub.introOffer.price });
const duration = this.formatPeriodCount(sub, sub.introOffer.durationPeriods);
phases.push(`${intro} for ${duration}`);
}
phases.push(this.formatPrice(sub));

return phases.join(", then ");
}

The two promotions are not mutually exclusive, which is the trap here. A plan can carry a free trial and an introductory price at once, and the subscriber gets both in sequence. The trial runs first, capped at 14 days. Then every configured discounted period. Then the standard price. Returning early on trialEligible would quietly hide the intro price and show the player billing terms that never happen. Collecting the phases in order handles all four combinations, from $9.99/mo up to Free trial, then $4.99/mo for 3 months, then $9.99/mo.

durationPeriods counts billing periods, not months, so read it against billingPeriod rather than assuming.

Sandbox players are a partial exception, and only a partial one. introOffer and retentionOffer are always null for them, because a checkout already forced to $0 carries no discount. A free trial still applies on their first subscribe, so trialEligible is worth handling in test runs too.

The game renders an offer button for inactive subscriptions and a "Cancel" button for the active one:

private async subscribe(sku: string): Promise<void> {
const result = await JestSDK.payments.beginSubscription({
subscriptionSku: sku,
});

if (result.result === "cancel") return;
if (result.result === "error") {
console.error("Subscription failed:", result.error);
return;
}

// Apply the entitlement immediately, then refresh the offer list.
this.isPremium = true;
void this.renderSubscriptions();
}

Three things to notice:

No grant-before-confirm dance. Unlike a product purchase, a successful beginSubscription is the whole transaction. Apply the entitlement right away from the returned data, then re-read getSubscriptions() so your UI reflects the platform's source of truth.

Cancellation is deferred. cancelSubscription opens a confirmation dialog. If the player confirms, the subscription stays active until the end of the current billing period. So the game doesn't flip isPremium itself on cancel. It just re-reads getSubscriptions(), which keeps reporting "active" until the period actually ends.

Try to win them back first. If the plan configures a retention discount and the player is still eligible, retentionOffer is present on the active subscription. Reigning Cats offers it before taking the cancel:

private async cancelMembership(sub: SubscriptionData): Promise<void> {
if (sub.retentionOffer) {
this.renderRetentionOffer(sub);
return;
}
await this.confirmCancel(sub.sku);
}

renderRetentionOffer swaps the offer list for two buttons, "Stay for $4.99/mo (3 months)" and "Cancel anyway", wired to claimRetentionOffer and cancelSubscription respectively. Note that it renders inline buttons rather than calling window.confirm. A browser modal blocks the SDK's message bridge for as long as it is open.

claimRetentionOffer applies the discount instantly, with no checkout. The subscription is unchanged apart from price, and the next durationPeriods renewals bill at the discounted rate before the standard price returns:

const result = await JestSDK.payments.claimRetentionOffer({
subscriptionSku: sku,
});

Each player can claim a given subscription's discount only once, and not during a free trial or an active introductory window. Those cases fail with not_eligible. Re-claiming an already-claimed discount re-confirms the same terms and succeeds, so retrying after an error is safe. After a successful claim, retentionOffer comes back null, which is what makes the next cancel go straight through.

getSubscriptions() also returns a signed JWS. For any entitlement that matters, verify it server-side with your shared secret before granting, the same pattern as products and referrals. Guests get the catalog as well, so the offer list renders before a player registers.

For the full lifecycle, error codes, and signed-payload shapes, see Subscriptions. To configure offers, see Subscriptions in the Developer Console.

Profiles, avatars, and the leaderboard​

Reigning Cats puts a face on every screen using the social module. Two methods do all the work.

On the start screen, registered players see their own avatar. getProfile() returns a profile only for registered players who have set one up, so the call is guarded:

const profile = JestSDK.social.getProfile({ avatarSize: 128 });
if (avatarEl && profile?.avatarUrl) {
avatarEl.src = profile.avatarUrl;
avatarEl.style.display = "block";
}

The game-over screen shows a "TOP CATS" leaderboard. Real players are rare against a handful of bot rivals, so the empty slots are filled with deterministic bot avatars. The same username always yields the same avatar, so the leaderboard looks stable across rounds:

JestSDK.social.getBotAvatar({ username: "SirPounce", size: 64 });

GameOverScene.leaderboardEntries mixes three bots around the player's high score and sorts by score, so the player's own row slots into the standings. Guests have no profile avatar, so the game falls back to a bot avatar seeded on their chosen name, so every row has a face either way. Avatars can be requested at a fixed size to keep network usage down.

Taking the screenshot yourself​

A camera button sits in the Jest footer while your game is running. Tapping it asks your game for a screenshot and opens a share sheet, where the player captions the image and posts it to your game's chat room or the global one, shares it to another app, or downloads it. A screenshot in chat is a player showing your game to other players, so it is worth making sure the capture is the frame you'd want them to see.

By default the SDK captures the main canvas for you, and for most games that is the right answer. Reigning Cats overrides it, because its start screen, shop, and membership panels are HTML overlays sitting above the Phaser canvas. A canvas capture taken while the shop is open would show the game behind it, not the shop.

src/main.ts registers a provider that declines in exactly that case:

JestSDK.social.setScreenshotProvider(() => {
const overlay = document.getElementById("name-input-container");
if (overlay && overlay.offsetParent !== null) {
return null;
}
return captureCanvas(game, "image/png");
});

Returning null means "no screenshot right now". Use it while loading or on a screen you don't want captured. Passing null instead of a function unregisters the provider and restores automatic capture. The provider may be async, and it must return a base64 PNG, raw or as a data URL.

captureCanvas lives in src/snapshot.ts and wraps Phaser's renderer.snapshot(), which hands the image to a callback on the next render rather than returning it:

game.renderer.snapshot(
(result) => {
settle(result instanceof HTMLImageElement ? result.src : null);
},
type,
quality,
);

Reading the canvas inside that callback is what makes it work on a WebGL renderer. Reading it straight from an event handler yields a blank frame, because the drawing buffer is only valid between the render and the frame's composite. The helper also serializes calls, since Phaser allows one snapshot per frame, and gives up after two seconds so a hidden or paused game resolves null instead of hanging.

To check what your provider returns, open the JestSDK debug menu, expand Screenshots, and click Capture Screenshot (see Test screenshot capture). That previews the image but not the sharing flow around it; for the real footer button and share sheet, run the game in the hosted emulator.

For the profile shape and the full list of avatar sizes, see Social.

Inviting friends​

Referrals are scoped by a reference key you choose, a stable string that groups conversions for analytics. Reigning Cats uses share_score. The share button lives in GameOverScene.ts:

const shareImage = await captureCanvas(this.game, "image/jpeg");

await JestSDK.referrals.shareReferralLink({
reference: REFERRAL_REFERENCE,
shareTitle: "Reigning Cats",
shareText: `I scored ${this.finalScore} in Reigning Cats! Can you beat me?`,
entryPayload: { referrer_name: this.playerName },
shareImage: shareImage ?? undefined,
notificationTemplates: [
{
minConversionCount: 1,
variants: [
{
title: "Your invite landed",
body: `${this.playerName}, a friend just joined Reigning Cats through your link.`,
ctaText: "See Scores",
},
],
},
/* ...a second template at minConversionCount: 3... */
],
});

Two options are doing extra work here.

shareImage personalizes the link preview. It takes a base64 data URL, which becomes the og:image on the referral's landing page. The preview in the messaging app then shows this player's actual score instead of your static store art. The game reuses the same captureCanvas helper as the screenshot provider, but asks for JPEG, because the data URL is capped at 2 MB. The accepted types are image/png, image/jpeg, image/webp, and image/gif. Animated GIFs are hosted unmodified, so they keep animating in previews that support it. The platform decodes, hashes, deduplicates, and hosts the bytes on its CDN. Note the game-over screen is a real Phaser scene with no HTML overlay on top, which is exactly why a canvas snapshot is the right source here.

notificationTemplates tells the referrer when invites convert. Each template applies above its minConversionCount threshold. The platform picks the template with the highest threshold the player has passed, then a variant from within it. That turns a one-shot share into a loop that brings the referrer back, not just the invitee.

The entryPayload rides along on the shared link. When a friend taps it and lands in the game, JestSDK.getEntryPayload().referrer_name is set, and the game shows a "{name} invited you!" toast on entry. That's the same pattern we used for notifications: bake context into the payload so the receiving session can act on it.

To display conversion counts (only registered players who completed signup count), GameOverScene reads the list and counts entries for its reference key:

const { referrals } = await JestSDK.referrals.listReferrals();
const count = (referrals[REFERRAL_REFERENCE] ?? []).length;

listReferrals() also returns referralsSigned (an HS256 JWS). For anything that affects entitlements, currency, or competitive balance, verify it server-side before granting rewards. See Referrals for a complete example, including a server-side feature-unlock pattern.

Run it on Jest​

When you're happy with the local game, ship it.

  1. Build. npm run build produces a dist/ folder. Zip its contents (not the folder itself).
  2. Create the game in the Developer Console. See Apps.
  3. Upload the zip as a new version. See Builds.
  4. Configure products. Add extra_life and slow_down in the Developer Console with USD prices. See Products.
  5. Configure the membership subscription. Add the membership offer with its USD price and billing period. If you want the trial, introductory, and win-back branches to appear, configure those on the plan too. See Subscriptions.
  6. Upload the notification images. The three references in src/retention.ts (score_defend, cats_miss_you, challengers_closing) must exist in the game's Image Library and read Pass before the series can be scheduled. The source art is in notification-assets/. Moderation is not instant, so do this before you start testing notifications. See Manage images.
  7. Test on Jest with a sandbox user. Sandbox users let you exercise the real platform, including login, purchases, subscriptions, and notifications, without spending real money or relying on production accounts. They're also how you see the sandbox: true branch light up. See Sandbox users.
  8. Submit a self-review through the Simulator. Select Submit for Review in your app's settings, then play through the build and pass every applicable automated Basic Launch check. The checklist covers readiness via markGameLoaded(), the D1 to D7 sequence, catalog fetch, purchase recovery, and subscriptions, with additional checks for Fund applicants. The selected app's catalog and notification assets are loaded into the Simulator. Your SDK activity and notes are attached to the build, and you can optionally include a screen recording. See Submit a self-review.

Where to go next​

  • SDK reference. Each section above linked to its sdk/html5/* page. The HTML5 SDK index lists them all.
  • Testing options. Beyond local mocks, you have the hosted emulator for running local code inside Jest.com, sandbox users for testing uploaded builds, and the Simulator for recording self-reviews.
  • Launch checklist. Before you submit for review, walk through the launch checklist.