DEV Community

unity source code
unity source code

Posted on

Adding Leaderboards and Achievements to a Unity Puzzle Game: A Systems Breakdown

In my last post, I walked through the tile-collection match mechanic slot-based matching, win/loss detection, level data structures, the works. A few people asked a reasonable follow-up question: okay, the core loop works, now what? What actually turns a finished prototype into something players open more than once?

The honest answer is retention systems, and the two cheapest, most reliable ones you can add are leaderboards and achievements. Neither is glamorous. Neither will fix a weak core loop. But bolted onto a mechanic that already works, they're some of the highest return-on-effort systems you can build, and they're a genuinely good exercise in state management, event-driven architecture, and separating your game logic from your presentation layer — skills that transfer to basically every other system you'll build afterward.

This post walks through the actual architecture: how I'd structure the data models, where the hooks into your core loop should live, and the mistakes that turn a simple feature into a tangled mess if you don't think about them up front.

Why This Matters More Than It Sounds Like It Should

If you've shipped a game before, you already know that getting someone to open your app a second time is often harder than getting the first download. A tile-matching puzzle with no reason to return is a game people finish (or abandon) once and delete. The same game with a persistent leaderboard and a handful of well-designed achievements gives players an external reason to come back — competing with a friend's score, chasing the next unlock, closing out a completion percentage.

None of that changes your core mechanic. It changes the meta-layer wrapped around it, and that meta-layer is disproportionately responsible for Day 7 and Day 30 retention numbers compared to how much development time it actually costs.

Designing the Data Model First

Before writing a single line of UI code, decide what you're actually tracking. For a tile-matching puzzle like the one from my last post, a reasonable starting model looks like this:

[System.Serializable]
public class PlayerStats
{
    public int highScore;
    public int totalMatchesCleared;
    public int totalLevelsCompleted;
    public int currentWinStreak;
    public int bestWinStreak;
    public List<string> unlockedAchievementIds = new List<string>();
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

Keep this separate from your gameplay state entirely. PlayerStats shouldn't know anything about tiles, slots, or match logic — it just accumulates numbers over time. This separation is what saves you from a tangled mess later: your gameplay code fires events, and something else entirely is responsible for listening to those events and updating stats.

Event-Driven Hooks Instead of Direct Calls

The mistake I see most often — and one I made myself the first time I built this — is calling stat-tracking code directly from inside gameplay logic:

// Don't do this
void ClearMatch(List<Tile> matchedTiles)
{
    // ... match clearing logic ...
    PlayerStats.Instance.totalMatchesCleared++;
    CheckAchievements(); // gameplay code shouldn't know achievements exist
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

This works fine for a five-minute prototype and becomes a nightmare the moment you want to add a new achievement, change how streaks are calculated, or reuse the same gameplay code in a different game mode. Instead, fire an event and let a dedicated manager listen for it:

public static class GameEvents
{
    public static event Action<int> OnMatchCleared;
    public static event Action<int> OnLevelCompleted;
    public static event Action OnLevelFailed;

    public static void MatchCleared(int tileCount) => OnMatchCleared?.Invoke(tileCount);
    public static void LevelCompleted(int score) => OnLevelCompleted?.Invoke(score);
    public static void LevelFailed() => OnLevelFailed?.Invoke();
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

Your gameplay code now just fires the event and moves on:

void ClearMatch(List<Tile> matchedTiles)
{
    // ... match clearing logic ...
    GameEvents.MatchCleared(matchedTiles.Count);
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

And a separate StatsManager subscribes and owns all the bookkeeping:

public class StatsManager : MonoBehaviour
{
    void OnEnable()
    {
        GameEvents.OnMatchCleared += HandleMatchCleared;
        GameEvents.OnLevelCompleted += HandleLevelCompleted;
        GameEvents.OnLevelFailed += HandleLevelFailed;
    }

    void OnDisable()
    {
        GameEvents.OnMatchCleared -= HandleMatchCleared;
        GameEvents.OnLevelCompleted -= HandleLevelCompleted;
        GameEvents.OnLevelFailed -= HandleLevelFailed;
    }

    void HandleMatchCleared(int tileCount)
    {
        PlayerStats.Instance.totalMatchesCleared++;
        AchievementManager.Instance.CheckMatchAchievements();
    }

    void HandleLevelCompleted(int score)
    {
        PlayerStats.Instance.totalLevelsCompleted++;
        PlayerStats.Instance.currentWinStreak++;
        PlayerStats.Instance.bestWinStreak = Mathf.Max(
            PlayerStats.Instance.bestWinStreak,
            PlayerStats.Instance.currentWinStreak
        );

        if (score > PlayerStats.Instance.highScore)
        {
            PlayerStats.Instance.highScore = score;
            LeaderboardManager.Instance.SubmitScore(score);
        }

        AchievementManager.Instance.CheckLevelAchievements();
    }

    void HandleLevelFailed()
    {
        PlayerStats.Instance.currentWinStreak = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

Now your gameplay code has zero knowledge of achievements, leaderboards, or streak logic. You can gut and rebuild the entire retention system without touching a single line of match-clearing logic, and vice versa — you can rework your tile-matching mechanic without worrying about breaking stat tracking.

Structuring Achievements So They Scale

The other common mistake is hardcoding achievement conditions directly in code — an if statement per achievement, scattered across your codebase. It works for three achievements. It falls apart at twenty. Define achievements as data instead:

[CreateAssetMenu(fileName = "Achievement", menuName = "Game/Achievement")]
public class AchievementData : ScriptableObject
{
    public string achievementId;
    public string displayName;
    public string description;
    public AchievementType type;
    public int targetValue;
}

public enum AchievementType
{
    TotalMatches,
    TotalLevelsCompleted,
    WinStreak,
    HighScore
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

Then your AchievementManager just iterates over a list of these ScriptableObjects and checks the relevant stat against each one's targetValue, instead of hardcoding a check per achievement:

public class AchievementManager : MonoBehaviour
{
    public static AchievementManager Instance { get; private set; }
    [SerializeField] private List<AchievementData> allAchievements;

    void Awake() => Instance = this;

    public void CheckMatchAchievements() => EvaluateType(AchievementType.TotalMatches, PlayerStats.Instance.totalMatchesCleared);
    public void CheckLevelAchievements()
    {
        EvaluateType(AchievementType.TotalLevelsCompleted, PlayerStats.Instance.totalLevelsCompleted);
        EvaluateType(AchievementType.WinStreak, PlayerStats.Instance.currentWinStreak);
        EvaluateType(AchievementType.HighScore, PlayerStats.Instance.highScore);
    }

    void EvaluateType(AchievementType type, int currentValue)
    {
        foreach (var achievement in allAchievements)
        {
            if (achievement.type != type) continue;
            if (PlayerStats.Instance.unlockedAchievementIds.Contains(achievement.achievementId)) continue;

            if (currentValue >= achievement.targetValue)
            {
                UnlockAchievement(achievement);
            }
        }
    }

    void UnlockAchievement(AchievementData achievement)
    {
        PlayerStats.Instance.unlockedAchievementIds.Add(achievement.achievementId);
        GameEvents.AchievementUnlocked(achievement); // fire an event for UI to react to
    }
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

Designing a new achievement is now a data task, not a code task — create a new AchievementData asset in the editor, set its type and target value, done. No recompiling, no new if statement buried somewhere in your stats manager.

Local Leaderboards vs. Platform Leaderboards

There are really two tiers here, and it's worth building them as separate concerns rather than assuming you'll only ever need one.

Local leaderboards are just a sorted list of scores stored on-device — useful for single-player high score tracking even before you've integrated any platform services. A simple approach using PlayerPrefs for a lightweight top-10 list works fine early on:

public class LeaderboardManager : MonoBehaviour
{
    public static LeaderboardManager Instance { get; private set; }
    const string LeaderboardKey = "local_leaderboard";
    const int MaxEntries = 10;

    void Awake() => Instance = this;

    public void SubmitScore(int score)
    {
        List<int> scores = LoadScores();
        scores.Add(score);
        scores.Sort((a, b) => b.CompareTo(a));
        if (scores.Count > MaxEntries) scores = scores.GetRange(0, MaxEntries);
        SaveScores(scores);
    }

    List<int> LoadScores()
    {
        string raw = PlayerPrefs.GetString(LeaderboardKey, "");
        if (string.IsNullOrEmpty(raw)) return new List<int>();
        var parts = raw.Split(',');
        var result = new List<int>();
        foreach (var p in parts) if (int.TryParse(p, out int val)) result.Add(val);
        return result;
    }

    void SaveScores(List<int> scores)
    {
        PlayerPrefs.SetString(LeaderboardKey, string.Join(",", scores));
    }
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

Platform leaderboards — Google Play Games Services on Android, Game Center on iOS — are what actually give you cross-device, social, competitive leaderboards, and they're what most players mean when they think "leaderboard." These require SDK integration rather than a homegrown system, and the implementation details (authentication flow, submitting scores through the platform API, showing the native UI) differ meaningfully between platforms and are honestly enough to warrant their own dedicated walkthrough rather than trying to cram it into this post.

If you're at the point of wiring in the real platform SDKs — Play Games Services setup, Game Center authentication, achievement syncing across both platforms — I'd point you to this full walkthrough on adding leaderboards and achievements to a Unity game, which goes through the platform-specific setup in more depth than makes sense to duplicate here. The architecture in this post (event-driven stats, data-driven achievements) is exactly what you'd plug that platform integration into — the LeaderboardManager.SubmitScore() method above is where you'd eventually call PlayGamesPlatform.Instance.SubmitScore() or the iOS equivalent instead of (or alongside) the local implementation.

The UI Layer: React to Events, Don't Poll

The last piece worth getting right is how your UI finds out an achievement unlocked. Polling PlayerStats every frame to check for changes is wasteful and awkward. Instead, subscribe to the event you already defined:

public class AchievementToastUI : MonoBehaviour
{
    [SerializeField] private GameObject toastPrefab;
    [SerializeField] private Transform toastParent;

    void OnEnable() => GameEvents.OnAchievementUnlocked += ShowToast;
    void OnDisable() => GameEvents.OnAchievementUnlocked -= ShowToast;

    void ShowToast(AchievementData achievement)
    {
        var toast = Instantiate(toastPrefab, toastParent);
        toast.GetComponent<ToastDisplay>().Setup(achievement.displayName, achievement.description);
    }
}
Enter fullscreen mode Exit fullscreen mode

Enter fullscreen mode

Exit fullscreen mode

This is the same pattern as your StatsManager — the UI layer knows nothing about how achievements are evaluated, it just reacts to an event firing. If you later change how achievements are unlocked (server-side validation, for instance), the UI code doesn't need to change at all.

Where This Fits Into the Bigger Picture

If you worked through the tile-collection matcher from my previous post, everything above bolts directly onto that project without touching the core match-detection logic — that's the whole point of keeping gameplay and meta-systems decoupled through events. GameEvents.MatchCleared() and GameEvents.LevelCompleted() just need to be called from the same places your slot-matching and win/loss logic already lives.

The broader lesson generalizes past this one genre, too. Any Unity project — puzzle, runner, idle, arcade — benefits from the same separation: gameplay systems fire events, a stats layer listens and accumulates, an achievement layer evaluates data-driven conditions, and a UI layer reacts to whatever the stats and achievement layers decide happened. Once you've built this pattern once, adding it to your next project is mostly copy-and-adapt rather than starting from zero.

If you're building this into an existing project and want the deeper platform-integration details — actual Play Games Services and Game Center setup, achievement syncing, and the edge cases around offline score submission — the full leaderboards and achievements guide is the better resource for that layer specifically. Everything in this post is the architecture you'd want in place before you get there.

Top comments (0)