Progression

Progression (XP/Levels)

Multi-track server-granted xp/levels; curve is config exponential or an IProgressionCurve SPI.

Server
services.AddCrossplayProgression();
Unity package
com.crossplay.progression
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.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> LeveledUp

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

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

3202 ProgressionStatus

Server → client: the full per-track snapshot for the selected character — the reply to a ProgressionStatusRequest.

3203 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> LeveledUp

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