Progression (XP/Levels)
Multi-track server-granted xp/levels; curve is config exponential or an IProgressionCurve SPI.
Seams you implement 2
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayProgression(); and it wins.
IProgressionCurve game SPI
SPI the GAME implements to define the shape of the leveling curve — how much xp completes each level on a track. The framework calls it after every Grant to decide when a track levels up and to report each track's "xp needed for next level" in a status snapshot.
// A battle pass: every tier costs a flat 1000 xp regardless of track or level.
public sealed class BattlePassCurve : IProgressionCurve
{
public long XpToComplete(ushort trackId, int level) => 1000;
} -
long XpToComplete(ushort trackId, int level)Xp required to advance FROM level to the next level on the track.
IProgressionRewardGrantor game SPI
Optional SPI the GAME implements to decide what a level-up GRANTS. Called once per level gained; the returned opaque blob rides the ProgressionLevelUp push for the client to render or apply.
public sealed class MyGrantor : IProgressionRewardGrantor
{
public byte[] Grant(long characterId, ushort trackId, int newLevel)
{
// Apply server-side rewards here, and/or return a blob the client renders.
return System.BitConverter.GetBytes(newLevel * 10); // e.g. "gold awarded" for the toast
}
} -
byte[] Grant(long characterId, ushort trackId, int newLevel)Produces the opaque reward payload for one level-up (called AFTER the level is applied).
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.
IProgressionStore provider
Persistence seam for progression state. Default InMemoryProgressionStore keeps it in RAM (dev/tests); a document-backed override (via AddCrossplayPersistentStores) survives restarts. Register your own with services.AddSingleton<IProgressionStore, MyStore>() before AddCrossplayProgression.
-
ProgressionState Get(long characterId)Loads a character's persisted tracks, or null if the character has none yet.
-
void Save(ProgressionState state)Persists a character's full progression state (called after every grant that changes it).
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IProgressionService
Server-side progression API — the interface GAME CODE calls to award xp (Grant). Progress is SERVER-REPORTED and authoritative: there is deliberately no wire message that grants xp, so a client can never forge a level. Clients only READ their tracks (the status request) and receive level-up pushes. Resolve it from DI: [Inject] IProgressionService progression.
-
void Grant(ISession session, ushort trackId, long xp)Grants xp on a track for the session's selected character, applying every level-up the curve crosses. No-op when xp ≤ 0 or the session has no selected character.
-
void Grant(long characterId, ushort trackId, long xp)Grants xp DIRECTLY to a character by id — for server-side awards where the character may be OFFLINE (a match-completion payout to a participant who dropped in the reconnect grace). Same level-up processing and LeveledUp event as the session overload; the only difference is that the level-up client push is skipped (there may be no live session — the client re-reads its status on reconnect). No-op when xp ≤ 0 or characterId is 0.
-
event Action<long, ushort, int> LeveledUpRaised once per level gained, with (characterId, trackId, newLevel). Server-side hook for game/composition bridges; the client is notified separately via the level-up push.
-
ProgressionStatus StatusOf(ISession session)Snapshots every track's level / xp-into-level / next-threshold for the session's character.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
ProgressionOptions
Progression tuning (defaults here, never inline). The default curve is exponential: completing level N costs BaseXpPerLevel × CurveGrowth^(N-1), so config-only games need no code; games with bespoke tables register their own IProgressionCurve.
-
long BaseXpPerLevel { get; set; }Xp to complete level 1 on any track (the curve's base).
-
double CurveGrowth { get; set; }Per-level cost multiplier (1.0 = flat).
-
int MaxLevel { get; set; }Level cap for every track (0 = uncapped).
-
bool PushLevelUps { get; set; }Push ProgressionLevelUp to the session the moment a level is gained.
Wire messages 3
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
ProgressionStatusRequest Client → server: "list my tracks." Carries no fields — the server resolves the requester's selected character from the session. Reply is a ProgressionStatus (or a NotEligible notification when no character is selected).
ProgressionStatus Server → client: the full per-track snapshot for the selected character — the reply to a ProgressionStatusRequest.
ProgressionLevelUp Server → client: pushed once per level gained (unsolicited, driven by a server-side xp grant). 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.
IProgressionClient
The Progression piece, client side: fetch the per-track snapshot and observe level-up pushes. Track ids and reward blobs are the GAME's vocabulary — this client never interprets them.
-
UniTask<ProgressionStatus> StatusAsync(CancellationToken ct = default)Requests the per-track level/xp snapshot.
-
event Action<ProgressionLevelUp> LeveledUpRaised the moment the server pushes a level-up.
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.
ICrossplayProgressionView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.