Tutorial: Reigning Cats
What you'll build
Reigning Cats is a small arcade game built with Phaser 3 and Vite. It looks like a toy, but the source is wired up the way a production messaging game should be: every Jest SDK feature is exercised end-to-end, with the patterns we recommend for real games.
This tutorial walks the codebase in the order a player 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, registration prompt at a meaningful moment, registered-player branch
- 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 / D3 / D7 series with cancellation on return
- Purchases —
extra_lifeandslow_downproducts, with crash-safe grant-before-confirm and startup recovery - Subscriptions — a membership offer that grants premium players a 2× score, read on every startup
- Referrals — share-to-invite with conversion counting
- Social — the player's profile avatar and deterministic bot avatars on a leaderboard
- Analytics — custom events captured at key funnel moments
- Loading screen — progress reported during Phaser asset preload
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's src/types/jestsdk.d.ts ambient declaration tells TypeScript about the surface so you get autocomplete without an npm package.
Call JestSDK.init() before any other SDK method, and don't construct your game until it resolves. From src/main.ts:
JestSDK.init().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.
init() accepts an autoLoginReminders option that controls the platform's automatic registration prompts. 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 dismissal happens when the game reports 100.
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.setLoadingProgress(100);
}
Two things to notice:
- The Phaser progress event reports
0..1; the SDK expects0..100. The game caps the preload progress at99so the overlay doesn't dismiss until the scene has actually finished setting up increate(). - The platform exits the player to home if no progress update arrives for 15 seconds. Each
JestSDK.setLoadingProgresscall resets that timer, so long loads are fine as long as you keep reporting.
For the full API and the auto / manual / off mode trade-offs, see Loading screen.
Identifying the player
Players enter as guests by default. Each player has a unique playerId per game; that identifier remains the same if the player later registers, so any data you've already saved carries over. Guest sessions can be lost when the player 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 hasMeaningfulScore =
this.finalScore >= SCORE_THRESHOLD_FOR_REG_PROMPT;
if (!hasMeaningfulScore) {
return;
}
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);
JestSDK.login({
entryPayload: {
reason: "save_first_score",
score: this.finalScore,
},
});
}
The two rules are:
- Wait for a meaningful moment. Only prompt after a score that shows the player is engaged. A registration dialog before the player has tasted the game just produces churn.
- Cool down between prompts. Track when you last asked. If the player declined, don't ask again for several games. The platform also sends its own automatic registration reminders at escalating intervals (controlled by the
autoLoginRemindersinit option), so your job is to layer in the in-context ask at the right moments.
The entryPayload passed to JestSDK.login is delivered back to the game on re-entry, which is useful if you want to react differently depending on what triggered the registration.
For the full player API and the alternative customized registration overlay, see Player and Platform login.
Saving progress
The platform key-value store is exposed at JestSDK.data. Reigning Cats keeps four keys:
| Key | Purpose |
|---|---|
playerName | Custom name, or fallback to platform username for registered players |
highScore | Best score seen so far |
gamesPlayed | Counter, used for the registration-prompt cooldown |
lastRegPromptGame | Last game number where we showed the registration dialog |
Reads happen lazily as needed. Writes batch — the SDK doesn't push to the server on every set. Before transitioning to the next scene, the game awaits a flush so a tab close mid-transition doesn't lose the new high score:
if (isNewHighScore) {
JestSDK.data.set("highScore", this.score);
}
JestSDK.data.set("gamesPlayed", gamesPlayed);
JestSDK.captureEvent("game_over", {
score: this.score,
gamesPlayed,
isNewHighScore,
});
// Flush before transitioning so a tab-close mid-transition doesn't lose the write.
await JestSDK.data.flush();
flush() returns a Promise<void>. If you don't await it, execution continues immediately and the scene can transition before the write reaches the server, silently dropping the values you just set.
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. FromGameScene.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 a three-message retention series. scheduleRetentionSeries(...) runs from GameOverScene after every round (registered players only — guests can't receive notifications), and unscheduleRetentionSeries() runs from GameScene when the player returns.
| Identifier | Day | Priority | Body |
|---|---|---|---|
retention_d1 | 1 | high | "{name}, your high score of {score} is under threat!" |
retention_d3 | 3 | medium | "Hey {name}, the cats miss you!" |
retention_d7 | 7 | low | "{name}, can you beat {score}?" |
Three patterns are worth calling out:
Fuzzy scheduling. Every notification uses scheduledInDays: 1..7 rather than an exact scheduledAt time. The platform picks the optimal delivery moment per player based on their messaging behavior — far better engagement than a fixed clock time you guess at.
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,
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.
Reigning Cats schedules a small D1/D3/D7 series for simplicity; for production games, Notifications best practices recommends a fuller D0–D7 rolling strategy and warns against the Inbox Groundhog Day trap (re-scheduling the same template every time the player returns). For the API itself, see Notifications.
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);
await JestSDK.payments.completePurchase({
purchaseToken: result.purchase.purchaseToken,
});
JestSDK.captureEvent("purchase", { sku: result.purchase.productSku });
} 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);
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) 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.
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 — renewals, expiries, cancellations — and 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. 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;
JestSDK.captureEvent("subscribe", { sku });
void this.renderSubscriptions();
}
Two 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.
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. Subscriptions are registered-only; guests always get an empty catalog.
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 — every row has a face either way. Avatars can be requested at a fixed size to keep network usage down.
For the profile shape and the full list of avatar sizes, see Social.
Measuring with analytics
Throughout the game, JestSDK.captureEvent(name, properties) records custom events that show up in the Developer Console. Reigning Cats fires three:
JestSDK.captureEvent("game_over", { score, gamesPlayed, isNewHighScore });
JestSDK.captureEvent("purchase", { sku });
JestSDK.captureEvent("subscribe", { sku });
Together they cover the core funnel: how far players get (game_over), and where they convert (purchase, subscribe). Use stable, lowercase, snake_case event names, attach JSON-serializable properties, and keep PII out of the payloads.
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:
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 },
});
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.
- Build.
npm run buildproduces adist/folder. Zip its contents (not the folder itself). - Create the game in the Developer Console. See Games.
- Upload the zip as a new version. See Builds.
- Configure products. Add
extra_lifeandslow_downin the Developer Console with USD prices. See Products. - Configure the membership subscription. Add the membership offer with its USD price and billing period. See Subscriptions.
- Test on Jest with a sandbox user. Sandbox users let you exercise the real platform — login, purchases, subscriptions, notifications — without spending real money or relying on production accounts. See Sandbox users.
- Record a self-review before submitting. The Simulator records a play-through of a specific version, which moderators watch alongside the build during review. See Testing with the Simulator.
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.