#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=Nis 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.