Achievements API
Achievements are the badges gamers brag about: they show on your game's page with an unlock percentage, in the player's trophy case on their gamer card, and each one is worth XP toward the player's arcade level. You define them in the portal, then unlock them from your game with one SDK call.
Needs SDK 1.1.0 or later (the current version is 1.1.0). The
script at /sdk/v1.js always serves the latest 1.x, so script-tag games get it
automatically. Bundler users: update @gems-arcadia/sdk.
1. Define achievements in the workshop#
Open your game at /developer and pick the ACHIEVEMENTS tab.
Each achievement has a key (what your code sends), a name, a description that
tells gamers how to earn it, a badge icon and an optional hidden flag.
Hidden achievements show as ??? until the player unlocks them.
- Up to 50 achievements per game.
key: 1–48 lower-case letters, digits,-and_, starting with a letter or digit. Locked once a player has unlocked it.- Name up to 40 characters, description up to 160.
- Every game achievement is worth +25 XP to the player.
Badge icons:
trophycoinjoystickcalendarstargempenheartfirecrownrocketticketmoonbolttargetshieldskullmap
2. Unlock from your game#
Script-tag games call GemsArcadia.unlockAchievement(key). With a bundler,
import unlockAchievement from @gems-arcadia/sdk, plus its types:
import type { GemsArcadia, UnlockAchievementResult } from "@gems-arcadia/sdk";unlockAchievement(key) never rejects. It resolves with one of:
| Result | Meaning |
|---|---|
{ ok: true, newlyUnlocked: true, unlocked: true, ... } | First unlock. The arcade is playing the celebration. |
{ ok: true, newlyUnlocked: false, unlocked: true, ... } | The player already had it. Nothing changed. |
{ ok: true, testMode: true, unlockedAt: null, ... } | Preview or mock mode: celebrated (preview) but not stored. |
{ ok: false, reason } | See error codes. |
The success fields:
| Field | Type | Meaning |
|---|---|---|
achievement.key | string | The key you sent. |
achievement.name | string | Name from the workshop. |
achievement.description | string | Description from the workshop. |
achievement.iconKey | string | One of the badge icon keys above. |
achievement.secret | boolean | The hidden flag. |
unlocked | boolean | The player has it (false only in mock mode). |
newlyUnlocked | boolean | This call unlocked it. false on repeats: unlocking is idempotent. |
unlockedAt | string | null | ISO time of the first unlock; null in test mode. |
testMode | boolean | Preview or mock session: nothing was stored. |
The samples below are type-checked against the SDK's own types and run in CI, so they always match the SDK version above. Start with your keys:
/** The keys you typed into your game's ACHIEVEMENTS tab in the workshop. */
export const ACHIEVEMENTS = {
wallBreaker: "wall-breaker",
comboCrusher: "combo-crusher",
gemVeteran: "gem-veteran",
} as const;
export type AchievementKey = (typeof ACHIEVEMENTS)[keyof typeof ACHIEVEMENTS];Game loops tend to notice a milestone many frames in a row. A small tracker
keeps it to one request per key, and tries again after a TIMEOUT:
/** `GemsArcadia` (the global) or the `@gems-arcadia/sdk` module both fit. */
type Arcade = Pick<GemsArcadia, "unlockAchievement">;
/** Failures worth trying again on the next trigger. */
const RETRYABLE: ReadonlySet<string> = new Set(["TIMEOUT", "ERROR"]);
/**
* Asks the arcade once per key, however often your game loop calls it.
* Unlocking is idempotent on the arcade too: this just saves round trips.
*/
export function createAchievementTracker(arcade: Arcade) {
const settled = new Set<AchievementKey>();
const inFlight = new Map<AchievementKey, Promise<UnlockAchievementResult>>();
function unlock(key: AchievementKey): Promise<UnlockAchievementResult> | null {
if (settled.has(key)) return null;
let pending = inFlight.get(key);
if (!pending) {
pending = arcade.unlockAchievement(key).then((result) => {
inFlight.delete(key);
if (result.ok || !RETRYABLE.has(result.reason)) settled.add(key);
return result;
});
inFlight.set(key, pending);
}
return pending;
}
return { unlock, settled: (key: AchievementKey) => settled.has(key) };
}The arcade plays the full-screen ACHIEVEMENT UNLOCKED celebration (with sound) over your cabinet, so your own HUD message can stay small:
/**
* A line for your own HUD. The arcade already plays the full-screen
* ACHIEVEMENT UNLOCKED celebration, so keep this small (or skip it).
*/
export function unlockMessage(result: UnlockAchievementResult): string | null {
if (!result.ok) {
switch (result.reason) {
case "NOT_SIGNED_IN":
return "Log in to collect badges!";
case "EMAIL_NOT_VERIFIED":
return "Verify your email to collect badges!";
case "ACHIEVEMENT_NOT_FOUND": // typo, or retired in the workshop
case "INVALID_KEY": // not lower-case letters, digits, - and _
default:
return null;
}
}
if (result.testMode) return `TEST BADGE: ${result.achievement.name}`;
return result.newlyUnlocked ? `BADGE: ${result.achievement.name}` : null;
}Then hook it to the moments in your game:
export function wireAchievements(arcade: Arcade, show: (text: string) => void) {
const tracker = createAchievementTracker(arcade);
const award = (key: AchievementKey) =>
tracker.unlock(key)?.then((result) => {
const text = unlockMessage(result);
if (text) show(text);
});
return {
onLevelCleared: (level: number) => {
if (level === 1) void award(ACHIEVEMENTS.wallBreaker);
},
onCombo: (chain: number) => {
if (chain >= 10) void award(ACHIEVEMENTS.comboCrusher);
},
onRunEnded: (lifetimeGems: number) => {
if (lifetimeGems >= 1000) void award(ACHIEVEMENTS.gemVeteran);
},
};
}Error codes#
reason | HTTP from the arcade | What happened | What to do |
|---|---|---|---|
INVALID_KEY | none (checked in the SDK) | Not 1–48 lower-case letters, digits, - or _. | Fix the key in your code. |
ACHIEVEMENT_NOT_FOUND | 404 | No active achievement with that key on this game (typo, or retired). | Check the ACHIEVEMENTS tab. |
SESSION_NOT_FOUND | 404 | The play session isn't this player's. | Reload the game. |
GAME_UNAVAILABLE | 404 | The game was unpublished or suspended mid-session. | Nothing. |
SESSION_EXPIRED | 410 | The session ended or idled for 30 minutes. | Reload to keep playing. |
NOT_SIGNED_IN | 401 / 403 | The player isn't logged in. | Invite them to log in. |
EMAIL_NOT_VERIFIED | 403 | Players verify their email before earning rewards. | Ask them to verify. |
INVALID_REQUEST | 400 | Malformed body (the SDK never sends one). | Update the SDK. |
TIMEOUT | none | No answer within 10 s. It may or may not have unlocked. | Call again later: it's idempotent. |
ERROR | 5xx / network | Anything else. | Call again later. |
Under the hood: the REST call#
Your game never calls this endpoint itself (it can't: it runs in a sandboxed
iframe without the player's cookies). The SDK posts an unlockAchievement
bridge message, and the arcade page calls:
POST /api/v1/sessions/{sessionId}/achievements
Content-Type: application/json
Cookie: auth4_session=...
{ "key": "wall-breaker" }{
"achievement": {
"key": "wall-breaker",
"name": "Wall Breaker",
"description": "Smash every gem on level 1.",
"iconKey": "trophy",
"secret": false
},
"unlocked": true,
"newlyUnlocked": true,
"unlockedAt": "2026-10-03T12:00:00.000Z",
"testMode": false,
"rewards": [
{
"kind": "achievement",
"title": "Wall Breaker",
"subtitle": "Smash every gem on level 1. +25 XP",
"icon": "🏆"
}
]
}Errors are { "error": "<CODE>", "message": "..." } with the HTTP status in
the table above. rewards drives the arcade's celebration and is not passed
on to your game. The full schema is in the
REST API reference, and what an unlock earns the player is
in player rewards.