Tutorial 123: Achievements and Leaderboards with Local Persistence
What you’ll learn
- Awarding achievements that survive a restart, with no online service involved.
- Writing scores and reading ranked, paged leaderboards.
- Where the data lands on disk, and how it is written safely.
- Exactly which parts of
GamerServicesdo nothing — stated plainly.
Before you start — Tutorial 19: Saving and Loading Data covers StorageDevice, which decides where this data is written.
XNA’s GamerServices namespace was a front end for Xbox Live. CNA has no online service and is never going to grow one, so a fair question is what is left. The answer is more than you would expect: achievements and leaderboards are backed by a real local implementation that genuinely persists, written as JSON under the platform’s preferences directory and reloaded by the next run of your game.
That is a meaningful difference from the no-op shim other reimplementations ship. It 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 parts that are inert are inert honestly, and they 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:
#include "Microsoft/Xna/Framework/GamerServices/GamerServicesComponent.hpp"
#include "Microsoft/Xna/Framework/GamerServices/SignedInGamer.hpp"
#include "Microsoft/Xna/Framework/Storage/StorageDevice.hpp"
using namespace Microsoft::Xna::Framework::GamerServices;
MyGame::MyGame()
{
// Decides the on-disk directory everything below is written to. Set it once, early.
Storage::StorageDevice::SetAppNameEXT("MyGame");
gamerServices_ = new GamerServicesComponent(*this);
getComponentsProperty().Add(gamerServices_);
}
void MyGame::Initialize()
{
Game::Initialize();
localGamer_ = (*Gamer::getSignedInGamersProperty())[0];
}
Note the shape: Gamer::getSignedInGamersProperty() returns a pointer to a SignedInGamerCollection, so you dereference it before indexing. Indexing by int gives you a SignedInGamer*; there is also an overload taking a PlayerIndex, which returns nullptr for an empty slot. Count with getCountProperty(), or use range-for.
There is no sign-in step to perform. The dispatcher creates a set of local gamers when it initialises, so index 0 is available from Initialize() onwards and achievements work immediately. If you want your own gamer identities — local profiles, say — publish them with Gamer::setSignedInGamersProperty().
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. Heap-allocate them and pass pointers.
Achievements
Awarding is one call, and it writes to disk immediately:
localGamer_->AwardAchievement("FIRST_BLOOD");
Reading back what has been earned:
AchievementCollection earned = localGamer_->GetAchievements();
std::set<std::string> earnedKeys;
for (const Achievement& a : earned)
{
earnedKeys.insert(a.getKeyProperty());
System::DateTime when = a.getEarnedDateTimeProperty();
// ... show it in your UI ...
}
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.
Only three fields round-trip: the key, the earned flag, and the earned timestamp. Name, description, how-to-earn, gamer score and the display-before-earned flag are not persisted — AwardAchievement only ever takes a key, exactly as in real XNA. Keep your own catalogue of achievement metadata in your game’s content, and use GetAchievements() purely to find out which keys are earned.
Build that catalogue with the CNAEXT factory, then merge in the earned state:
// Your own authored catalogue - name, description, visibility.
std::vector<Achievement> catalogue = {
Achievement::CreateInternal("FIRST_BLOOD", "First Blood",
"Defeat your first enemy", true, false, System::DateTime{}),
Achievement::CreateInternal("SPEEDRUN", "Fleet of Foot",
"Finish level 1 in under 60 seconds", true, false, System::DateTime{}),
};
// Which of them are actually earned, from disk. Match on the KEY.
const bool firstBlood = earnedKeys.count("FIRST_BLOOD") != 0;
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 authored catalogue entry will never compare equal to the sparse record that comes back from disk. Key comparison is the only reliable join.
Achievement is immutable once constructed, so “marking one earned” in your UI means constructing a new value with earned = true and swapping it into your own list.
Achievement::GetPicture() always throws System::NotImplementedException. There is no achievement artwork store. Ship your icons as ordinary content and look them up by key.
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 or commit call. You fetch the gamer’s entry for that board and set its rating; the write is what persists it:
localGamer_->getLeaderboardWriterProperty()
.GetLeaderboard(board)
->setRatingProperty(finalScore);
Ratings are long long. For a time-based board, store whatever ordering you want to sort descending — ranking is always highest-rating-first, so a “fastest time” board needs an inverted value.
LeaderboardEntry also carries a PropertyDictionary of extra columns via getColumnsProperty(), which is persisted alongside the rating.
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 and assigned after a rating-descending sort of the whole board.
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();
A persisted entry only appears if its gamertag is currently signed in. Entries are matched back to live Gamer objects by gamertag, and any record with no match is skipped. If you are building a local-multiplayer high-score table, publish every relevant gamer through Gamer::setSignedInGamersProperty() before reading.
The Begin/End pattern is synchronous
Every Begin*/End* pair in this module — BeginAwardAchievement, BeginGetAchievements, LeaderboardReader::BeginRead, BeginPageDown, BeginPageUp — completes inline. The work is already done and your callback has already run by the time Begin* returns.
System::IAsyncResult* result = localGamer_->BeginAwardAchievement(
"FIRST_BLOOD",
[](System::IAsyncResult&) { /* runs BEFORE BeginAwardAchievement returns */ },
std::any{});
localGamer_->EndAwardAchievement(result);
delete result; // the IAsyncResult is yours to free
This is faithful to XNA, whose “async” here was always a fiction over an instant local operation — but it matters for your code. Do not write a state machine that waits for completion in a later frame; it will never be pending. And note the ownership: the returned IAsyncResult* is caller-owned.
BeginGetAchievements additionally throws System::InvalidOperationException if one is already outstanding.
Where the data lives
Everything is written under the platform preferences directory that StorageDevice resolves through SDL_GetPrefPath, in a GamerServices subdirectory:
| Data | Path under the storage root |
|---|---|
| Achievements | GamerServices/achievements/<gamertag>.json |
| Leaderboards | GamerServices/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.
Writes go to a temporary file and are then renamed over the target. That matters more than it sounds: without it, a crash or power loss during a write would leave a truncated, unparseable file, and the next run would lose every previously earned achievement, not just the one being written. If the rename fails — a temp directory on another filesystem, for instance — there is a direct-write fallback rather than a silent loss.
Reading is equally defensive: a missing or corrupt file yields an empty result instead of an exception.
None of this persists on the web. Under Emscripten SDL_GetPrefPath resolves into an in-memory filesystem that is discarded on page reload, and CNA mounts nothing durable behind it. Achievements will appear to work for the length of a session and then vanish. See Tutorial 124.
What does nothing
Being able to trust the working parts means being precise about the rest.
Fifteen documented no-op overlay entry points
Fourteen Guide::Show* overloads plus Guide::DelayNotifications are documented no-ops: their bodies are empty. They compile, they return, and nothing appears.
| No-op | No-op | No-op |
|---|---|---|
ShowSignIn | ShowGamerCard | ShowFriends |
ShowFriendRequest | ShowComposeMessage | ShowMessages |
ShowGameInvite (both overloads) | ShowParty | ShowPartySessions |
ShowPlayers | ShowPlayerReview | ShowMarketplace |
ShowAchievementsEXT | DelayNotifications |
Every one of them is an Xbox Live shell surface with no local equivalent, so a no-op is the honest implementation. If you want an achievements screen, draw one yourself — you have the catalogue and the earned keys.
Two Guide pairs are genuinely real, and unlike the achievement calls above they do not complete synchronously — they complete when the player responds. BeginShowMessageBox/EndShowMessageBox renders a real overlay with buttons and mouse hit-testing; BeginShowKeyboardInput/EndShowKeyboardInput renders a real text prompt, with an optional password-masking mode. EndShowMessageBox returns a std::optional<int> button index, and EndShowKeyboardInput the entered string.
AvatarRenderer::Draw is inert by design
AvatarRenderer::Draw() validates its arguments — it throws if the bone array is the wrong length or the renderer is disposed — and then genuinely does nothing. That is faithful: XNA’s avatars were an Xbox-only asset store, and CNA has no avatar data to draw.
The working path is the CNAEXT one. EnableRealRenderingEXT() binds a skinned model you supply, and DrawRealEXT() renders it with real GPU skinning. If you want avatars, that is the route — the XNA-spec Draw() will stay a no-op.
Other flat surfaces
SignedInGamer::GetFriends()returns an empty collection, andIsFriend()is always false. There is no friends graph.Achievement::GetPicture()throws.PropertyDictionary::CopyTo()throws.
Testing it
Because persistence is a real filesystem write, achievement logic is straightforward to test — give the test its own app name so it writes somewhere disposable:
Storage::StorageDevice::SetAppNameEXT("MyGameTests");
This is exactly how CNA’s own tests isolate themselves. See Tutorial 99: Unit Testing for the harness.