Achievements & Stats

Track player stats, auto-unlock achievements at thresholds, persist progress to disk, and publish unlock events.

Declare achievements in data tables; the framework tracks, unlocks, and saves them.

InterfaceIAchievementService
Off switchNullAchievementService
AssemblyCommonGameSystem.Core
StartupRegistered automatically at boot; you load the definition tables

What it does

Achievements and stats track player progress and award recognition. You declare both in ScriptableObject tables: a stat is a named counter ("enemies_defeated", "playtime_seconds"), and an achievement is an id plus display text, optionally bound to a stat with a target threshold. The service records stat changes, checks unlock conditions the moment a stat moves, saves everything to disk through the Save/Load service, and fires an AchievementUnlocked event on the Event Bus whenever an achievement unlocks.

The division of labor is deliberate: the framework owns tracking, unlocking, and persistence; your UI owns the toast or popup. The module is completely headless — it contains no rendering code, so it works with any UI approach.

Quick start

Load the tables once, then record stats from gameplay code:

var achievements = ServiceLocator.Resolve<IAchievementService>();
achievements.AddToStat("enemies_defeated", 1);

Show a notification by subscribing to the unlock event:

ServiceLocator.Resolve<IEventBus>().Subscribe<AchievementUnlocked>(evt =>
{
    Debug.Log($"Unlocked: {evt.AchievementId}");
});

API reference

Table loading

  • void LoadTables(IReadOnlyList<AchievementTableAsset> achievements, IReadOnlyList<StatTableAsset> stats) — Mandatory first call. Builds the definitions and lookup indexes. Calling it again replaces everything and resets in-memory state.

Stat operations

  • long AddToStat(string statId, long delta) — Adds to (or subtracts from) a stat. Overflow-safe and floored at 0 by default. Immediately re-checks achievements bound to the stat. Returns the new value.
  • long SetStat(string statId, long value) — Sets a stat to an absolute value, with the same floor and threshold checks. Returns the stored value.
  • long GetStat(string statId) — Reads the current value. An unknown id returns 0L. Allocates nothing.

Unlocking

  • void Unlock(string achievementId) — Directly unlocks an achievement that is not bound to a stat (story milestones, secret finds). Safe to call twice; an already-unlocked achievement fires no second event.
  • bool IsUnlocked(string achievementId) — Whether the achievement is unlocked. An unknown id returns false.
  • bool TryGetProgress(string achievementId, out AchievementProgress progress) — Fills a progress snapshot (current value, target, completion ratio, state). Returns true for any known achievement, stat-bound or not; false only for an unknown id.

Queries

  • void GetVisible(List<AchievementDefinition> results) — Fills your list with visible achievements (not hidden, or hidden but already unlocked). Clears the list first. Allocates nothing — you own the list.
  • void GetAll(List<AchievementDefinition> results) — Fills your list with every definition, including locked hidden ones. Clears the list first. Allocates nothing.
  • bool ContainsAchievement(string id) — Whether an achievement id is defined.
  • bool ContainsStat(string id) — Whether a stat id is defined.

Persistence

  • SaveResult Save() — Writes in-memory state to disk through the Save/Load service. Skips the write when nothing has changed. Returns the save status.
  • void Load() — Reads saved state from the save slot named "achievements". A missing or corrupt save loads as empty state, silently. Already-unlocked achievements do not re-fire their events.
  • void ResetAll() — Clears all stats back to their initial values and locks every achievement, in memory only. Call Save() afterward to persist the reset.

Example: full setup

using System.Collections.Generic;
using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class AchievementManager : MonoBehaviour
{
    [SerializeField] private AchievementTableAsset[] _achievementTables; // assign in the Inspector
    [SerializeField] private StatTableAsset[] _statTables;               // assign in the Inspector

    private IAchievementService _achievements;
    private IEventBus _bus;

    private void Start()
    {
        _achievements = ServiceLocator.Resolve<IAchievementService>();
        _bus = ServiceLocator.Resolve<IEventBus>();

        // Load the table definitions first, then the saved state from disk.
        _achievements.LoadTables(_achievementTables, _statTables);
        _achievements.Load();

        // Subscribe to unlock events.
        _bus.Subscribe<AchievementUnlocked>(OnAchievementUnlocked);
    }

    public void OnEnemyDefeated()
    {
        long newCount = _achievements.AddToStat("enemies_defeated", 1);
        Debug.Log($"Enemies defeated: {newCount}");
    }

    private void OnAchievementUnlocked(AchievementUnlocked evt)
    {
        Debug.Log($"Achievement unlocked: {evt.AchievementId}");
        // Show your toast or popup here.
    }
}

Turning it off

ServiceLocator.Replace<IAchievementService>(new NullAchievementService());

This disables the whole module: table loading does nothing, all mutations are no-ops, GetStat returns 0L, IsUnlocked returns false, no disk writes happen, and no events are published. Use it to test without achievements or to ship a minimal build.

Common pitfalls

  • LoadTables is mandatory. Call it before any stat or achievement operation. AddToStat on an empty service logs a warning but does not crash.
  • Unlocks write to disk immediately by default. The FlushOnUnlock option defaults to true, so each unlock performs a synchronous save through the Save/Load service. If many achievements can unlock in a burst, set FlushOnUnlock = false in AchievementOptions and call Save() yourself at checkpoints.
  • Threshold checks happen immediately. The moment AddToStat or SetStat runs, bound achievements are re-evaluated. There is no deferred "check progress later" — the event fires as soon as the condition is met.
  • Unlocking is one-way. Once an achievement unlocks, it stays unlocked. Even if the stat later decreases (with the AllowNegativeDeltas option enabled), the achievement does not re-lock.
  • Progress events are opt-in. Set PublishProgress = true on an achievement definition in the table to enable the AchievementProgressChanged event. It is off by default to avoid event spam from high-frequency stats such as playtime_seconds.
  • Custom types need link.xml on IL2CPP. The module's own public types (such as AchievementSaveData) are already preserved. If you serialize your own types with JsonUtility in a player build, add entries to your project's link.xml so the IL2CPP build does not strip them.
  • Save / Load — save slots and atomic writes behind Save() and Load()
  • Event Bus — the AchievementUnlocked and AchievementProgressChanged events