Progression

Achievements

Server-reported counters against game-defined thresholds; catalog/reward are game SPIs.

Server
services.AddCrossplayAchievements();
Unity package
com.crossplay.achievements
Depends on
core

Seams you implement 2

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayAchievements(); and it wins.

IAchievementCatalog game SPI

SPI the GAME implements to declare its achievements — the source of every id, counter key, and threshold. The framework holds no built-in achievements; it only counts the reports your game makes and unlocks the definitions this catalog exposes.

public sealed class MyCatalog : IAchievementCatalog
{
    public IReadOnlyList<AchievementDefinition> Definitions { get; } = new[]
    {
        new AchievementDefinition(1, "kills", 10),    // "Slayer"      — 10 kills
        new AchievementDefinition(2, "kills", 100),   // "Exterminator"— 100 kills (same counter, tier)
        new AchievementDefinition(3, "gold_earned", 1000),
    };
}
  • IReadOnlyList<AchievementDefinition> Definitions { get; }

    Every achievement the game defines. Read on each report and each snapshot; keep it stable.

IAchievementRewardGrantor game SPI

Optional SPI the GAME implements to decide what an unlock GRANTS. Called once per achievement unlocked; the returned opaque blob rides the AchievementUnlocked push for the client to render or apply.

public sealed class MyGrantor : IAchievementRewardGrantor
{
    public byte[] Grant(long characterId, ushort achievementId)
    {
        // Apply the reward server-side, and/or return a blob the client renders.
        return System.Array.Empty<byte>();
    }
}
  • byte[] Grant(long characterId, ushort achievementId)

    Produces the opaque reward payload for one unlock (called AFTER the unlock is recorded).

Providers you can swap 1

Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.

IAchievementStore provider

Persistence seam for achievement state. Default InMemoryAchievementStore keeps it in RAM (dev/tests); a document-backed override (via AddCrossplayPersistentStores) survives restarts. Register your own with services.AddSingleton<IAchievementStore, MyStore>() before AddCrossplayAchievements.

  • AchievementState Get(long characterId)

    Loads a character's counters + unlocks, or null if the character has none yet.

  • void Save(AchievementState state)

    Persists a character's full achievement state (called after every report that changes it).

Services you call 1

Crossplay implements these. Resolve them from DI and call them from your own systems.

IAchievementService

Server-side achievements API — the interface GAME CODE calls to report progress (Report). Progress is SERVER-REPORTED and authoritative: there is deliberately no wire message that increments a counter, so a client can never forge an unlock. Clients only READ their snapshot and receive unlock pushes. Resolve it from DI: [Inject] IAchievementService achievements.

  • void Report(ISession session, string counterKey, long delta = 1)

    Adds delta to a counter for the session's selected character, unlocking any definitions whose threshold is crossed (each unlocks exactly once). No-op when delta ≤ 0, the key is empty, or the session has no selected character.

  • AchievementList SnapshotOf(ISession session)

    Builds the full progress + unlock snapshot for the session's selected character.

  • event Action<long, ushort> Unlocked

    Raised once per unlock, with (characterId, achievementId). Server-side hook for game/composition bridges; the client is notified separately via the unlock push.

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

AchievementOptions

Achievements tuning (defaults here, never inline).

  • bool PushUnlocks { get; set; }

    Push AchievementUnlocked to the session the moment it unlocks.

Wire messages 3

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

3101 AchievementListRequest

Client → server: "list my progress and unlocks." Carries no fields — the server resolves the requester's selected character from the session. Reply is an AchievementList (or a NotEligible notification when no character is selected).

3102 AchievementList

Server → client: the full progress/unlock snapshot for the selected character — the reply to an AchievementListRequest.

3103 AchievementUnlocked

Server → client: pushed the moment an achievement unlocks (unsolicited, driven by a server-side report). RewardData is the game grantor's opaque blob (empty when no grantor is wired) — the game renders/applies it.

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

IAchievementClient

The Achievements piece, client side: fetch the progress snapshot and observe unlock pushes. Ids and reward blobs are the GAME's vocabulary — this client never interprets them.

  • UniTask<AchievementList> ListAsync(CancellationToken ct = default)

    Requests the full progress/unlock snapshot.

  • event Action<AchievementUnlocked> Unlocked

    Raised the moment the server pushes an unlock.

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayAchievementsView

Narrow contract the presenter drives. UI-only: render calls in, user intents out.