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.
| Interface | IAchievementService |
| Off switch | NullAchievementService |
| Assembly | CommonGameSystem.Core |
| Startup | Registered 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 returns0L. 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 returnsfalse.bool TryGetProgress(string achievementId, out AchievementProgress progress)— Fills a progress snapshot (current value, target, completion ratio, state). Returnstruefor any known achievement, stat-bound or not;falseonly 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. CallSave()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
LoadTablesis mandatory. Call it before any stat or achievement operation.AddToStaton an empty service logs a warning but does not crash.- Unlocks write to disk immediately by default. The
FlushOnUnlockoption defaults totrue, so each unlock performs a synchronous save through the Save/Load service. If many achievements can unlock in a burst, setFlushOnUnlock = falseinAchievementOptionsand callSave()yourself at checkpoints. - Threshold checks happen immediately. The moment
AddToStatorSetStatruns, 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
AllowNegativeDeltasoption enabled), the achievement does not re-lock. - Progress events are opt-in. Set
PublishProgress = trueon an achievement definition in the table to enable theAchievementProgressChangedevent. It is off by default to avoid event spam from high-frequency stats such asplaytime_seconds. - Custom types need
link.xmlon IL2CPP. The module's own public types (such asAchievementSaveData) are already preserved. If you serialize your own types withJsonUtilityin a player build, add entries to your project'slink.xmlso the IL2CPP build does not strip them.
Related pages
- Save / Load — save slots and atomic writes behind
Save()andLoad() - Event Bus — the
AchievementUnlockedandAchievementProgressChangedevents