Skip to content
GEMSARCADIA
▸ MANUAL CONTENTS(Achievements API)
GEMS ARCADIA · COIN-OP SERVICE MANUAL · FORM GA-DEV-1SEC. 2.2

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:

achievements.ts · types
import type { GemsArcadia, UnlockAchievementResult } from "@gems-arcadia/sdk";

unlockAchievement(key) never rejects. It resolves with one of:

ResultMeaning
{ 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:

FieldTypeMeaning
achievement.keystringThe key you sent.
achievement.namestringName from the workshop.
achievement.descriptionstringDescription from the workshop.
achievement.iconKeystringOne of the badge icon keys above.
achievement.secretbooleanThe hidden flag.
unlockedbooleanThe player has it (false only in mock mode).
newlyUnlockedbooleanThis call unlocked it. false on repeats: unlocking is idempotent.
unlockedAtstring | nullISO time of the first unlock; null in test mode.
testModebooleanPreview 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:

achievements.ts · 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:

achievements.ts · once per key
/** `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:

achievements.ts · handling results
/**
 * 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:

achievements.ts · game moments
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#

reasonHTTP from the arcadeWhat happenedWhat to do
INVALID_KEYnone (checked in the SDK)Not 1–48 lower-case letters, digits, - or _.Fix the key in your code.
ACHIEVEMENT_NOT_FOUND404No active achievement with that key on this game (typo, or retired).Check the ACHIEVEMENTS tab.
SESSION_NOT_FOUND404The play session isn't this player's.Reload the game.
GAME_UNAVAILABLE404The game was unpublished or suspended mid-session.Nothing.
SESSION_EXPIRED410The session ended or idled for 30 minutes.Reload to keep playing.
NOT_SIGNED_IN401 / 403The player isn't logged in.Invite them to log in.
EMAIL_NOT_VERIFIED403Players verify their email before earning rewards.Ask them to verify.
INVALID_REQUEST400Malformed body (the SDK never sends one).Update the SDK.
TIMEOUTnoneNo answer within 10 s. It may or may not have unlocked.Call again later: it's idempotent.
ERROR5xx / networkAnything 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:

HTTP
POST /api/v1/sessions/{sessionId}/achievements
Content-Type: application/json
Cookie: auth4_session=...

{ "key": "wall-breaker" }
JSON
{
  "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.

REV 1.0 · KEEP THIS MANUAL WITH THE CABINET · QUESTIONS? ASK GLINT