achievements

Achievements

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

Category Progression Seams 3 Services 1 Options 1 Wire ids 3 Unity types 2
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.

Example
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.

Example
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.

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

    Adds delta to a counter for a character DIRECTLY by id, for server-side awards where the character may be OFFLINE (a match-completion credit to someone who dropped in the reconnect grace). Same counters, same unlock-once rule and same Unlocked event as the session overload; the only difference is that the unlock push is skipped, because there may be no live session — the client reads its list back on reconnect. No-op when delta ≤ 0, the key is empty, or characterId is 0.

  • 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).

  • List<AchievementRow> Catalog { get; set; }

    The achievement catalog as DATA (id, counter key, threshold) — so a game declares its achievements in config instead of writing an IAchievementCatalog. Used only when rows exist and the game registered no catalog of its own (a game's catalog always wins). Empty (the default) = the inert catalog, exactly as before.

  • 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.