Progression

Quests

Accept/track/turn-in over game-defined objectives; catalog/policy/reward are game SPIs.

Server
services.AddCrossplayQuests();
Unity package
com.crossplay.quests
Depends on
core

Seams you implement 3

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

IQuestCatalog game SPI

SPI the GAME implements to define its quests — the source of every quest id, its repeatable flag, and its objectives. The framework ships no quests; it only tracks progress against the definitions this catalog returns.

public sealed class MyCatalog : IQuestCatalog
{
    private readonly Dictionary<ushort, QuestDefinition> _byId = new()
    {
        [1] = new QuestDefinition(1, repeatable: false,
                  new QuestObjectiveDefinition("kill_wolf", 5),
                  new QuestObjectiveDefinition("collect_pelt", 5)),
    };
    public QuestDefinition? Get(ushort questId) => _byId.GetValueOrDefault(questId);
}
  • QuestDefinition Get(ushort questId)

    Resolves a quest definition by id.

IQuestPolicy game SPI

SPI the GAME implements to gate quest ACCEPTANCE — prerequisites, level gates, faction checks, "one per chain" rules. Called after the framework's own checks (unknown id, already active, log full) pass, so implement only game rules here. Returning false refuses the accept (the client gets an AcceptRefused verdict).

public sealed class MyPolicy : IQuestPolicy
{
    public bool CanAccept(ISession session, long characterId, QuestDefinition quest)
        => quest.Id != 42 || PlayerLevelOf(characterId) >= 10; // gate quest 42 behind level 10
}
  • bool CanAccept(ISession session, long characterId, QuestDefinition quest)

    Approve (or veto) a character accepting a quest.

IQuestRewardGrantor game SPI

Optional SPI the GAME implements to decide what a turn-in GRANTS. Called once per turn-in; the returned opaque blob rides the QuestTurnInResponse for the client to render or apply.

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

    Produces the opaque reward payload for one turn-in (called AFTER the turn-in 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.

IQuestStore provider

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

  • QuestState Get(long characterId)

    Loads a character's active + completed quests, or null if the character has none yet.

  • void Save(QuestState state)

    Persists a character's full quest state (called after every accept/report/turn-in that changes it).

Services you call 1

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

IQuestService

Server-side quests API — accept / track / turn-in over the game's catalog. Clients send accept, log, and turn-in requests; objective PROGRESS is SERVER-REPORTED (Report) with no wire mutation path, so it can never be client-forged. Resolve it from DI: [Inject] IQuestService quests.

  • QuestLog LogOf(ISession session)

    Builds the quest-log snapshot (active quests + completed quest ids) for the session's character.

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

    Advances matching objectives on the character's ACTIVE quests. Server game-code only — no-op when delta ≤ 0, the key is empty, or the session has no selected character.

  • bool TryAccept(ISession session, ushort questId, out ushort reason)

    Attempts to accept a quest for the session's selected character, running the framework checks (known id, not already active, log not full) then the game's IQuestPolicy.

  • bool TryTurnIn(ISession session, ushort questId, out byte[] rewardData, out ushort reason)

    Attempts to turn in a completed active quest, granting its reward and recording completion.

  • event Action<long, ushort> TurnedIn

    Raised once per turn-in, with (characterId, questId). Server-side hook for game/composition bridges (chain quests, mirror to another piece).

Configuration 1

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

QuestOptions

Quests tuning (defaults here, never inline).

  • int MaxActiveQuests { get; set; }

    Active quests per character (0 = unlimited).

  • bool PushProgress { get; set; }

    Push QuestProgress to the session as objectives advance.

Wire messages 7

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

3301 QuestLogRequest

Client → server: "send me my quest log." Carries no fields — the server resolves the requester's selected character from the session. Reply is a QuestLog (or a NotEligible notification when no character is selected).

3302 QuestLog

Server → client: the quest-log snapshot — active quests + already turned-in quest ids. The reply to a QuestLogRequest.

3303 QuestAcceptRequest

Client → server: "accept this quest from the game's catalog." The server validates the id and runs the game's accept policy, then replies with a QuestAcceptResponse.

3304 QuestAcceptResponse

Server → client: the accept verdict — the reply to a QuestAcceptRequest.

3305 QuestTurnInRequest

Client → server: "turn in this completed quest." The server verifies the quest is active and complete, then replies with a QuestTurnInResponse.

3306 QuestTurnInResponse

Server → client: the turn-in verdict — the reply to a QuestTurnInRequest. RewardData is the game grantor's opaque blob — the framework never knows what a reward IS.

3307 QuestProgress

Server → client: pushed when an active quest's objective advances (unsolicited, driven by a server-side report).

Unity services you inject 1

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

IQuestClient

The Quests piece, client side: log/accept/turn-in plus live objective pushes. Quest ids and reward blobs are the GAME's vocabulary — this client never interprets them.

  • UniTask<QuestLog> LogAsync(CancellationToken ct = default)

    Requests the quest-log snapshot.

  • UniTask<QuestAcceptResponse> AcceptAsync(ushort questId, CancellationToken ct = default)

    Accepts a quest (refusals carry the reason code).

  • UniTask<QuestTurnInResponse> TurnInAsync(ushort questId, CancellationToken ct = default)

    Turns in a completed quest (the response carries the game's opaque reward).

  • event Action<QuestProgress> ProgressChanged

    Raised as active-quest objectives advance.

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.

ICrossplayQuestsView

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