Tutorial 123: Achievements and Leaderboards, Offline and on the Server

CNA Tutorials  ·  Services

ℹ

What you’ll learn

  • Awarding achievements that survive a restart for a local offline profile, and describing them with an achievement catalog.
  • Writing scores and reading ranked, paged leaderboards.
  • What changes when the game is connected to a CNA Gamer Services server.
  • Where the offline data lands on disk, and the limits and trust model to design around.

Before you start — Tutorial 19: Saving and Loading Data covers StorageDevice, which decides where offline data is written, and the Gamer Services page explains local profiles, the Guide and the optional server.

XNA’s GamerServices namespace was a front end for Xbox LIVE. CNA is not Xbox LIVE, but it implements the namespace in two modes, and the same game code runs in both. Offline, the default, the signed-in gamers are local profiles, and achievements and leaderboards are kept in a real local store, written as JSON under the per-application storage root that StorageDevice resolves and read back by the next run. With a CNA Gamer Services server configured (outside your code, see the Gamer Services page), the gamers are server accounts, and achievements and leaderboards are provisioned and kept on that server.

That is enough to build and test a real achievement system against, and enough to ship a single-player game with a working local leaderboard. The differences between the two modes, and the trust model, are listed at the bottom of this page.

Getting a gamer

GamerServices is switched on by adding a GamerServicesComponent to your game’s components, exactly as XNA 4.0 required on PC, with a GraphicsDeviceManager so the Guide can draw:

#include "Microsoft/Xna/Framework/GamerServices/GamerServicesComponent.hpp"
#include "Microsoft/Xna/Framework/GamerServices/Gamer.hpp"
#include "Microsoft/Xna/Framework/GamerServices/Guide.hpp"
#include "Microsoft/Xna/Framework/GamerServices/SignedInGamer.hpp"
#include "Microsoft/Xna/Framework/GamerServices/SignedInGamerCollection.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"

using namespace Microsoft::Xna::Framework::GamerServices;

MyGame::MyGame()
{
    // Decides the on-disk directory the offline store is written to. Set it once, early.
    Storage::StorageDevice::SetAppNameEXT("MyGame");

    // gamerServices_ is a std::shared_ptr<GamerServicesComponent> member: the CNAEXT shared_ptr overload of
    // Add() makes the components collection keep it alive. graphics_ is the game's GraphicsDeviceManager.
    gamerServices_ = std::make_shared<GamerServicesComponent>(*this);
    getComponentsProperty().Add(gamerServices_);
}

void MyGame::Update(GameTime& gameTime)
{
    Game::Update(gameTime);   // runs GamerServicesDispatcher::Update
    localGamer_ = (*Gamer::getSignedInGamersProperty())[PlayerIndex::One];
    if (localGamer_ == nullptr && !Guide::getIsVisibleProperty())
        Guide::ShowSignIn(1, false);   // pick or create a local profile (or an account, with a server)
}

The component is driven by the base Game::Update, so your own Update override must call Game::Update(gameTime) (see Tutorial 47). Nobody is signed in at start-up: a gamer exists only once the player has picked or created a profile in the Guide’s sign-in page, or once the auto-sign-in setting has signed one in (for tests and CI, CNA_GAMER_SERVICES_AUTO_SIGN_IN=Alice creates and signs in a local profile named Alice). Note the shape: Gamer::getSignedInGamersProperty() returns a pointer to a SignedInGamerCollection, so you dereference it before indexing. Indexing by PlayerIndex returns that player’s SignedInGamer*, or nullptr when nobody is signed in there; indexing by int walks the collection by position. Count with getCountProperty(), or use range-for.

⚠

Never copy or move a constructed Gamer or SignedInGamer. Each one holds a LeaderboardWriter that captured this at construction, so a copy leaves a dangling owner pointer. Keep the pointers the collection hands you.

Achievements

Awarding is one call. For a local profile it writes to disk immediately; for a server account it is sent to the server. Awarding an achievement already earned keeps its first date, and a new award shows an “Achievement unlocked” notification in the Guide:

localGamer_->AwardAchievement("FIRST_BLOOD");

Reading back the achievements:

AchievementCollection achievements = localGamer_->GetAchievements();

for (const Achievement& a : achievements)
{
    if (!a.getIsEarnedProperty()) continue;
    const std::string& key = a.getKeyProperty();
    System::DateTime when = a.getEarnedDateTimeProperty();
    // ... show it in your UI, or let Guide::ShowAchievementsEXT(PlayerIndex::One) do it ...
}

AchievementCollection supports getCountProperty(), indexing by position and by key, Contains, IndexOf and range-for. Indexing by an unknown key throws System::IndexOutOfRangeException; indexing out of range throws System::ArgumentOutOfRangeException.

Describing achievements: the catalog

What GetAchievements() returns depends on whether the title describes its achievements:

  • Offline, with a catalog. A title can ship GamerServices/Achievements.json in its title directory: a JSON array of objects with key, name, description, howToEarn, score (0–1000), display and an optional picture (a title-relative PNG path). GetAchievements() then lists every defined achievement, earned or not, with its text, score and picture; AwardAchievement refuses a key the catalog does not define with GamerServicesNotAvailableException; and a malformed catalog is refused with an InvalidOperationException naming the file and the problem.
  • Offline, without a catalog. The achievements are the earned keys alone, with their earned dates and no text or score. Keep your own catalogue of names and icons in your game’s content and join on the key.
  • On a server. The operator provisions the title’s achievements on the server, and GetAchievements() returns that catalog with the account’s earned state.
[
  { "key": "FIRST_BLOOD", "name": "First Blood", "description": "Defeat your first enemy",
    "howToEarn": "Win one fight", "score": 10, "display": true, "picture": "Achievements/first_blood.png" }
]
⚠

Match on the key, never with Contains or ==. Achievement’s equality compares every field — name, description, gamer score, earned flag and timestamp included — so an entry from your own catalogue will not compare equal to the record that comes back. Key comparison is the reliable join.

Achievement::GetPicture() returns a caller-owned stream with the achievement’s PNG: from the catalog’s picture file offline, from the server for an account. When no picture is configured it throws GamerServicesNotAvailableException.

Leaderboards

A leaderboard is identified by a LeaderboardIdentity, built from one of four LeaderboardKey values — BestScoreLifeTime, BestScoreRecent, BestTimeLifeTime, BestTimeRecent — optionally with a game-mode number so one key can serve several modes:

LeaderboardIdentity board = LeaderboardIdentity::Create(LeaderboardKey::BestScoreLifeTime);
LeaderboardIdentity hardMode = LeaderboardIdentity::Create(LeaderboardKey::BestScoreLifeTime, 2);

Writing a score

There is no submit call. You fetch the gamer’s entry for that board and set its rating:

localGamer_->getLeaderboardWriterProperty()
           .GetLeaderboard(board)
           ->setRatingProperty(finalScore);

When that write is kept depends on the gamer:

  • A local profile: every setRatingProperty is persisted to the local store at once, with no session needed.
  • A server account: leaderboards can only be written during network gameplay. Outside a LocalWithLeaderboards, PlayerMatch or Ranked session in the Playing state, GetLeaderboard throws InvalidOperationException (“Leaderboards can only be written during network gameplay.”). The writes are collected as drafts and committed to the server when the host calls EndGame() (or a gamer leaves); a Ranked row is written only when a strict majority of the reporting machines agree.
#include "Microsoft/Xna/Framework/Net/NetworkSession.hpp"
using namespace Microsoft::Xna::Framework::Net;

// Server accounts: write inside a session that is Playing, commit at EndGame.
NetworkSession* session = NetworkSession::Create(NetworkSessionType::LocalWithLeaderboards, 1, 2);
session->StartGame();
session->Update();                                  // now Playing
localGamer_->getLeaderboardWriterProperty().GetLeaderboard(board)->setRatingProperty(finalScore);
session->EndGame();                                 // the host commits the drafts here
session->Update();
session->Dispose();
delete session;                                     // the session object is caller-owned

Ratings are long long. Offline, ranking is always highest-rating-first, so a “fastest time” board needs an inverted value; a server board is provisioned with its own sort order and aggregation (best or latest). LeaderboardEntry also carries a PropertyDictionary of extra columns via getColumnsProperty(), persisted alongside the rating (the offline store keeps no Stream columns).

Reading a page

LeaderboardReader has three static Read overloads — by page offset, centred on a pivot gamer, and centred on a pivot within an explicit gamer list:

// A page of 10 starting at the top
LeaderboardReader reader = LeaderboardReader::Read(board, 0, 10);

// A page of 10 centred on this player
LeaderboardReader around = LeaderboardReader::Read(board, localGamer_, 10);

The pivot form is the one worth using in a results screen: it centres the page on the player’s own position, so they see who is just above and just below them rather than a top-ten they are nowhere near.

const auto entries = reader.getEntriesProperty();

for (SharpRuntime::intcs i = 0; i < entries.getCountProperty(); ++i)
{
    const LeaderboardEntry& e = entries[i];
    std::printf("#%d  %s  %lld\n",
                e.getRankingEXTProperty(),                    // 1-based
                e.getGamerProperty()->getGamertagProperty().c_str(),
                e.getRatingProperty());
}

Note the accessor name: rank is getRankingEXTProperty(), a CNA extension, not getRankProperty(). Ranks are 1-based. Offline they are assigned after a rating-descending sort and count only the entries listed; a server returns global ranks (ties broken by account) in pages of up to 100.

Paging mutates the reader in place, and you must check first — paging past either end throws System::InvalidOperationException:

if (reader.getCanPageDownProperty())
{
    reader.PageDown();
}
if (reader.getCanPageUpProperty())
{
    reader.PageUp();
}

const int total = reader.getTotalLeaderboardSizeProperty();
const int start = reader.getPageStartProperty();
⚠

Offline, a persisted entry only appears if its gamertag is currently signed in on this machine. Entries are matched back to live Gamer objects by gamertag, and any record with no match is skipped. A local high-score table therefore shows the profiles signed in right now; a board shared between machines needs the server.

Begin/End: inline offline, pumped on the server

For a local profile every Begin*/End* pair in this module — BeginAwardAchievement, BeginGetAchievements, LeaderboardReader::BeginRead, BeginPageDown, BeginPageUp — completes inline: the work is done and your callback has already run by the time Begin* returns. For a server account the same calls are genuinely asynchronous: the result completes during GamerServicesDispatcher::Update, which the GamerServicesComponent runs from Game::Update, and an End* called earlier waits by pumping the dispatcher.

System::IAsyncResult* result = localGamer_->BeginAwardAchievement(
    "FIRST_BLOOD",
    [](System::IAsyncResult&) { /* offline: runs BEFORE BeginAwardAchievement returns */ },
    std::any{});

// Offline the result is already complete; on a server, wait for getIsCompletedProperty()
// in a later Update, or let End* wait.
localGamer_->EndAwardAchievement(result);
delete result;   // the IAsyncResult is yours to free

Write the code for the asynchronous case and it works in both: start the call, keep running frames, and finish it once it has completed. The returned IAsyncResult* is caller-owned. Offline, BeginGetAchievements additionally throws System::InvalidOperationException if one is already outstanding.

Where the offline data lives

For local profiles everything is written under the storage root that StorageDevice::GetStorageRootEXT() reports, in a GamerServices subdirectory. That root comes from $XDG_DATA_HOME (or ~/.local/share) on Linux, %LOCALAPPDATA% on Windows, ~/Library/Application Support on macOS, the app-private files directory on Android and the IndexedDB-backed /cna-storage mount in a browser, each followed by an application folder <app>; when none of those is available it falls back to ./<app>. <app> is the literal name game unless you called StorageDevice::SetAppNameEXT("MyGame"), as shown above:

DataPath under the storage root
AchievementsGamerServices/achievements/<gamertag>.json
LeaderboardsGamerServices/leaderboards/<key>_<gameMode>.json

Both file-name components are sanitised — anything outside letters, digits, -, _ and . becomes an underscore — so an exotic gamertag cannot escape the directory. The profiles themselves live elsewhere, in profiles.json under the user’s data directory (or CNA_GAMER_SERVICES_PROFILES_DIR).

Writes go to a temporary file that then replaces the target in one rename, under a lock, so two writers do not lose each other’s updates and a crash in the middle of a write does not leave a truncated file behind; a corrupt history is preserved rather than silently replaced. CNA does not promise more than the operating system’s file cache gives you: a power loss right after a write can still lose it.

⚠

In a browser the offline store sits under the IndexedDB-backed StorageDevice root, so by a reading of the source achievements and leaderboards should survive a page reload, while the local profiles, kept outside that mount, are not expected to; neither was tested for this page. A threaded web build without -DCNA_EMSCRIPTEN_USE_WASMFS=OFF has no persistent storage at all. See Tutorial 124.

Limits and trust

Being able to trust the working parts means being precise about the rest.

  • Scores and achievements are client claims. On a server, awards and scores are authenticated and validated, but they are still claims made by the game: a modified or colluding client can submit plausible fake results, and there is no anti-cheat. Offline, the JSON files are plain files the player can edit.
  • No TrueSkill, and no time window for ...Recent boards. The BestScoreRecent and BestTimeRecent keys behave like their lifetime counterparts.
  • Guests earn nothing. A guest of a signed-in account (server mode) cannot be awarded achievements.
  • Social features need the server. Friends, presence, messages and gamer pictures are not available to local profiles.
  • Qualification is Linux-first. CNA records its final audit run of this work on Linux (2 October 2026): the GamerServices suite 649 passed and the Net suite 524 passed, with no failures; CNA’s Apple campaign records its test trees passing on one Apple M4. Windows has not been qualified, and no public server instance exists — a server-backed game runs against a server you operate.

The Guide that shows the sign-in page and the achievement notifications is CNA’s own console-style system UI, with real pages for message boxes, keyboard input, sign-in, achievements and the social features; it is described on the Gamer Services page.

Testing it

Because offline persistence is a real filesystem write, achievement logic is straightforward to test — give the test its own app name so it writes somewhere disposable, and sign a test profile in through the environment:

Storage::StorageDevice::SetAppNameEXT("MyGameTests");
// and run the test with, for example:
//   CNA_GAMER_SERVICES_AUTO_SIGN_IN=Tester CNA_GAMER_SERVICES_PROFILES_DIR=/path/to/scratch ./MyGameTests

See Tutorial 99: Unit Testing for the harness.

Where to go next