---
title: "Track achievements and stats"
description: "Record player progress and display unlocked achievements."
seo:
  noindex: true
---

{/* Copyright (c) 2026 Betide Studio. All Rights Reserved. */}

Create matching achievement and stat IDs in the providers you enable under **Progression**. [Sign in](/crossplay/identity) before reading or writing progress.

## Unlock an achievement

**Blueprint**

<BlueprintViewer title="Unlock an achievement" src="/blueprints/crossplay-unlock-achievement.txt" />

**C++**

Game module dependencies: `UCIKCore`, `BetideCore`.

```cpp
#include "Engine/GameInstance.h"
#include "Progression/UCIKProgressionSubsystem.h"
#include "UCIKCore.h"

void UnlockFirstWin(UGameInstance &GameInstance)
{
    auto *Service = GameInstance.GetSubsystem<UUCIKProgressionSubsystem>();
    if (!Service)
        return;

    Service->UnlockAchievement(
        TEXT("FIRST_WIN"),
        FBetideCallback<TMap<FBetideProviderId, bool>>::CreateWeakLambda(
            &GameInstance, [](const TBetideResult<TMap<FBetideProviderId, bool>> &Result) {
                if (!Result.bOk)
                {
                    UE_LOG(LogUCIK, Warning, TEXT("%s"), *Result.Error.GetLogString());
                    return;
                }
                for (const auto &Pair : Result.GetValue())
                {
                    UE_LOG(LogUCIK, Log, TEXT("%s: %s"), *Pair.Key.ToString(),
                           Pair.Value ? TEXT("updated") : TEXT("not updated"));
                }
            }));
}
```
## Write a stat

**Blueprint**

<BlueprintViewer title="Write a stat" src="/blueprints/crossplay-write-stats.txt" />

**C++**

Game module dependencies: `UCIKCore`, `BetideCore`.

```cpp
#include "Engine/GameInstance.h"
#include "Progression/UCIKProgressionSubsystem.h"
#include "UCIKCore.h"

void RecordWin(UGameInstance &GameInstance)
{
    auto *Service = GameInstance.GetSubsystem<UUCIKProgressionSubsystem>();
    if (!Service)
        return;

    TMap<FString, FUCIK_StatValue> Stats;
    Stats.Add(TEXT("Wins"), FUCIK_StatValue::FromInteger(1));
    Service->WriteStats(
        Stats, FBetideCallback<TMap<FBetideProviderId, bool>>::CreateWeakLambda(
                   &GameInstance, [](const TBetideResult<TMap<FBetideProviderId, bool>> &Result) {
                       if (!Result.bOk)
                       {
                           UE_LOG(LogUCIK, Warning, TEXT("%s"), *Result.Error.GetLogString());
                           return;
                       }
                       for (const auto &Pair : Result.GetValue())
                       {
                           UE_LOG(LogUCIK, Log, TEXT("%s: %s"), *Pair.Key.ToString(),
                                  Pair.Value ? TEXT("updated") : TEXT("not updated"));
                       }
                   }));
}
```

For an EOS **SUM** stat, `1` adds one win. Steam stats set a stored value. With an aggregating authority first in the provider policy, UCIK reads back the stored total and sends that to mirrors. Do not retry a SUM write blindly: it can count twice.

## Read progress and definitions

**Blueprint**

<Accordion>
  <AccordionItem title="Read achievement progress">
<BlueprintViewer title="Read achievement progress" src="/blueprints/crossplay-query-achievements.txt" />
  </AccordionItem>
  <AccordionItem title="Read achievement definitions">
<BlueprintViewer title="Read achievement definitions" src="/blueprints/crossplay-achievement-definitions.txt" />
  </AccordionItem>
  <AccordionItem title="Set partial progress">
<BlueprintViewer title="Set partial progress" src="/blueprints/crossplay-achievement-progress.txt" />
  </AccordionItem>
  <AccordionItem title="Read win stats">
<BlueprintViewer title="Read win stats" src="/blueprints/crossplay-read-stats.txt" />
  </AccordionItem>
  <AccordionItem title="Write stats from a server">
<BlueprintViewer title="Write stats from a server" src="/blueprints/crossplay-server-stats.txt" />
  </AccordionItem>
</Accordion>

**C++**

| Call | Callback value |
| --- | --- |
| `QueryAchievements({}, Callback)` | `TArray<FUCIK_Achievement>` |
| `QueryAchievementDefinitions({}, Callback)` | `TArray<FUCIK_Achievement>` |
| `SetAchievementProgress(Id, Current, Max, Callback)` | `TMap<FBetideProviderId, bool>` |
| `ReadStats(Player, Names, Callback)` | `TArray<FUCIK_UserStats>` |
| `WriteStatsForPlayer(Player, Stats, Callback)` | `bool` |
| `UnlockAchievementForPlayer(Player, Id, Callback)` | `bool` |
| `OnAchievementUnlocked` / `OnAchievementUnlockedNative` | event: `FUCIK_PlayerHandle`, achievement id |

```cpp
#include "Engine/GameInstance.h"
#include "Engine/World.h"
#include "Progression/UCIKProgressionSubsystem.h"
#include "UCIKCore.h"

namespace ProgressionExamples
{
UUCIKProgressionSubsystem *Service(UGameInstance &Game)
{
    auto *Progression = Game.GetSubsystem<UUCIKProgressionSubsystem>();
    if (!Progression)
        UE_LOG(LogUCIK, Warning, TEXT("Progression subsystem is unavailable"));
    return Progression;
}

void ReadAchievementRows(UGameInstance &Game, bool bDefinitions)
{
    auto *Progression = Service(Game);
    if (!Progression)
        return;
    auto Callback = FBetideCallback<TArray<FUCIK_Achievement>>::CreateWeakLambda(
        &Game, [bDefinitions](const TBetideResult<TArray<FUCIK_Achievement>> &Result) {
            if (!Result.bOk)
            {
                UE_LOG(LogUCIK, Warning, TEXT("%s"), *Result.Error.GetLogString());
                return;
            }
            for (const FUCIK_Achievement &Achievement : Result.GetValue())
            {
                if (bDefinitions)
                    UE_LOG(LogUCIK, Log, TEXT("%s: %s"), *Achievement.DisplayName,
                           *Achievement.Description);
                else
                    UE_LOG(LogUCIK, Log, TEXT("%s: %f"), *Achievement.DisplayName,
                           Achievement.Progress);
            }
        });
    if (bDefinitions)
        Progression->QueryAchievementDefinitions({}, MoveTemp(Callback));
    else
        Progression->QueryAchievements({}, MoveTemp(Callback));
}

void RefreshAchievements(UGameInstance &Game)
{
    ReadAchievementRows(Game, false);
}
void ReadAchievementDefinitions(UGameInstance &Game)
{
    ReadAchievementRows(Game, true);
}

void UpdateWinProgress(UGameInstance &Game, int32 Wins)
{
    auto *Progression = Service(Game);
    if (!Progression)
        return;
    Progression->SetAchievementProgress(
        TEXT("WIN_TEN_MATCHES"), Wins, 10,
        FBetideCallback<TMap<FBetideProviderId, bool>>::CreateWeakLambda(
            &Game, [](const TBetideResult<TMap<FBetideProviderId, bool>> &Result) {
                if (!Result.bOk)
                {
                    UE_LOG(LogUCIK, Warning, TEXT("%s"), *Result.Error.GetLogString());
                    return;
                }
                for (const auto &Pair : Result.GetValue())
                    UE_LOG(LogUCIK, Log, TEXT("%s: %s"), *Pair.Key.ToString(),
                           Pair.Value ? TEXT("updated") : TEXT("not updated"));
            }));
}

void ReadWins(UGameInstance &Game, const FUCIK_PlayerHandle &Player)
{
    auto *Progression = Service(Game);
    if (!Progression)
        return;
    Progression->ReadStats(
        Player, {TEXT("Wins")},
        FBetideCallback<TArray<FUCIK_UserStats>>::CreateWeakLambda(
            &Game, [](const TBetideResult<TArray<FUCIK_UserStats>> &Result) {
                if (!Result.bOk)
                {
                    UE_LOG(LogUCIK, Warning, TEXT("%s"), *Result.Error.GetLogString());
                    return;
                }
                for (const FUCIK_UserStats &Row : Result.GetValue())
                {
                    if (const FUCIK_StatValue *Wins = Row.Stats.Find(TEXT("Wins")))
                        UE_LOG(LogUCIK, Log, TEXT("Wins: %lld"), Wins->IntegerValue);
                    else
                        UE_LOG(LogUCIK, Warning, TEXT("Wins was not returned by this provider"));
                }
            }));
}

void RecordServerWin(UGameInstance &Game, const FUCIK_PlayerHandle &Player)
{
    if (!Game.GetWorld() || Game.GetWorld()->GetNetMode() != NM_DedicatedServer)
    {
        UE_LOG(LogUCIK, Warning, TEXT("Run this on your trusted dedicated server"));
        return;
    }
    auto *Progression = Service(Game);
    if (!Progression)
        return;
    TMap<FString, FUCIK_StatValue> Stats;
    Stats.Add(TEXT("Wins"), FUCIK_StatValue::FromInteger(1));
    Progression->WriteStatsForPlayer(
        Player, Stats,
        FBetideCallback<bool>::CreateWeakLambda(&Game, [](const TBetideResult<bool> &Result) {
            if (!Result.bOk)
            {
                UE_LOG(LogUCIK, Warning, TEXT("%s"), *Result.Error.GetLogString());
                return;
            }
            UE_LOG(LogUCIK, Log, TEXT("Server stat write completed"));
        }));
}
} // namespace ProgressionExamples
```

Use an integer `Wins` stat and your configured `WIN_TEN_MATCHES` achievement ID. An empty **Provider** combines matching achievement IDs into one row with the highest progress; **Provider Progress** keeps each store's value. Match definitions and progress by achievement ID.

<Accordion>
  <AccordionItem title="Progress and partial results">

EOS derives partial achievement progress from stats; direct **Set Crossplay Achievement Progress** is not supported there. Its stat adapter uses 32-bit integers, rounding floating-point input.

Server stat writes require a trusted server and a provider with that capability. Client-side success is not proof of an authoritative game result.

**On Achievement Unlocked** on the progression subsystem fires whenever a provider confirms an unlock: the kit's own Unlock Crossplay Achievement, a server unlock, or an unlock the service applied itself from stats or progress. It carries the player's handle on that provider and the achievement id, so a crossplay unlock that reaches two stores fires once per store.

**Write Crossplay Stats (Server)** and **Unlock Crossplay Achievement (Server)** take the target's player handle. On EOS they use the server client policy. On Steam they use the game-server API when the process is a logged-on Steam game server that authenticated the player, and otherwise the publisher Web API with the key read from the `SIK_PUBLISHER_WEB_API_KEY` environment variable (or the per-developer editor ini). Without either route the Steam provider does not declare the server capabilities and the nodes fail with a not-supported error naming what is missing.

Write results list responding providers; failed providers can be absent. Check the returned provider IDs against the stores you expected. Overall success does not promise every store was updated. If an authority write or readback fails, mirrors can receive the original samples instead of a stored total. Re-read progress before claiming stores are synchronized.

  </AccordionItem>
</Accordion>
