#Achievements

Per-game unlocks worth Gamesight Score (GS), the arcade-wide points total shown beside a player's name. Declare them in a JSON file, unlock them with one SDK call, and the popup, the score, and the browse pages come for free.

Each game has a budget of 1000 GS, like an Xbox base game. Spend it however you like across up to 100 achievements.

#Declare them

Create games/<slug>/achievements.json:

{
  "$schema": "../../schema/achievements.schema.json",
  "achievements": [
    { "id": "first-flight", "name": "First Flight", "description": "Finish your first run.", "icon": "🚀", "points": 5 },
    { "id": "night-shift", "name": "Night Shift", "description": "Fly a run between midnight and 5am.", "icon": "🌙", "points": 160, "secret": true }
  ]
}
Field Rules
id a-z0-9-, ≤48, unique in your game. Never change or reuse one, because unlocks are stored against it.
name ≤48 characters
description ≤140. Write the instruction ("Survive 60 seconds"), not the brag.
icon An emoji, or an image path relative to your game directory (icons/ace.png). Optional.
points A multiple of 5, 5–200. All of them together must total ≤1000.
secret Hides the name and description until earned. Points always show.

Shipping the file lights the 🏆 chip on the landing page, so you don't also set features.achievements in game.json.

Every rule is enforced at build time, so a bad catalog fails CI rather than production. Run npm run validate to check yours.

After editing the file, regenerate the API's copy and commit it:

npm run gen-achievements

The Lambda can't read games/ at runtime, so definitions are baked into packages/api/src/achievements.generated.mjs. CI fails if it's out of date.

#Unlock from your game

<script src="/sdk/v1/arcade.js"></script>
Arcade.init();

await Arcade.achievements.unlock("first-flight");

That's the whole API. Already earned it? The server ignores the call and reports new: false, so fire it whenever the condition is met and never track what you've handed out. A new unlock raises the popup (bottom-centre, with a chime) automatically.

const res = await Arcade.achievements.unlock("first-flight");
// → { new: true, achievement: {...}, at: "2026-08-14T…", total: 165 }  ("total" is GS across ALL games)
// → null when signed out, because there's nobody to award it to

Other calls:

await Arcade.achievements.list();          // this game's achievements + unlock counts + your progress
await Arcade.achievements.unlockers("id"); // everyone who has it, first finder first
await Arcade.achievements.mine();          // your GS across every game | null signed out
Arcade.achievements.onUnlock((a) => …);    // react to unlocks yourself
Arcade.achievements.setMuted(true);        // silence the chime (popup still shows)

Turn the built-in popup off with Arcade.init({ achievementToasts: false }) if your game draws its own celebration. onUnlock still fires either way.

Calls reject with ArcadeOfflineError when the API is unreachable. Catch and carry on; achievements are never load-bearing for play.

#Unlock from your backend

A client-side unlock is as forgeable as a client-side score, since anyone can open devtools and call it. That's an acceptable trade for most games, and it's the only option for a game with no backend module.

If yours has one, award from verified server state instead:

import { grantAchievements } from "../achievements.mjs";

await grantAchievements(store, "sightlines", {
  account: player.pk,
  player: player.name,
  ids: ["first-blood", "grandmaster-down"],
});

grantAchievements skips anything already owned in a single query, and never throws; a bad id is logged and the rest still land. The usual shape is to re-derive the whole set from the player's record after every match rather than tracking which ones are outstanding; see pushAchievements in packages/api/src/games/sightlines.mjs.

Server-granted unlocks reach the popup through a poll, which your game opts into:

Arcade.achievements.watch();   // idempotent; polls every 20s and on tab focus

#Where players see them

  • The popup: bottom-centre, on unlock, with a synthesized chime.
  • /achievements/<slug>/: every achievement, how many players have each one, and who they are (in the order they earned it). Linked from the landing card, with the game's boards alongside at /leaderboard/<slug>/.
  • The header: a GS chip beside their name. Points always render as the GS mark (a rounded hexagon with "GS" knocked out of it) to the left of the number, like a currency symbol. The hexagon is currentColor, so it takes the colour of whatever text it sits beside, and the letters are drawn as paths, so the mark is identical everywhere and needs no webfont.
  • /profile: total GS, recent unlocks, and per-game progress.
  • The notification bell: a new unlock drops a notice in the player's inbox — game, points, name, description and how long ago — so it still reaches them if they'd navigated away when it landed. Both the notice and the popup name the game, because watch() means either can appear on a page that isn't the game's.
  • The landing page: the newest unlocks across the whole arcade, from anyone. GET /api/achievements/recent?limit=N is public, since player names already appear on leaderboards and unlocker lists.

#Style harness

/debug/ is not linked from anywhere, but is always deployed. Every achievement surface rendered from the real CSS with nothing behind it: no API, no session.

  • Fire the unlock popup with arbitrary content (icon, name, description, points) and watch the queue, the overflow behaviour, and the sound. Also available from your own console as Arcade.achievements.preview(a).
  • The GS mark at every size it ships at, down to the smallest, and over stripes so you can see the letters are genuinely knocked out.
  • Achievement rows in every state (locked, unlocked, secret, image icon, text that overflows), plus the progress bar, score chips and card footer.

Locally: npm run preview, then http://localhost:8002/debug/.

#Two worked examples

Game Path Look at
Starship pure client games/starship/src/main.ts, no backend at all, just unlock() calls
Sightlines trusted server packages/api/src/games/sightlines.mjs, granted off replay-verified match results

See also: leaderboards · accounts · saving game data.

View docs/guides/achievements.md on GitHub ↗