Achievements
Server-reported counters against game-defined thresholds; catalog/reward are game SPIs.
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> UnlockedRaised 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.
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).
AchievementList Server → client: the full progress/unlock snapshot for the selected character — the reply to an AchievementListRequest.
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> UnlockedRaised 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.