Economy (Currencies)
Atomic, policy-gated, ledgered currencies; read-only on the wire.
Seams you implement 1
Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayEconomy(); and it wins.
IEconomyPolicy game SPI
SPI the GAME implements to gate every currency mutation — where taxes, caps, level requirements, and region rules live. The framework calls the matching method BEFORE it touches a balance; returning false vetoes the operation (the corresponding TryCredit/TryDebit/TryTransfer returns false, no balance moves).
public sealed class MyPolicy : IEconomyPolicy
{
public bool AllowCredit(long ownerId, ushort currencyId, long amount, string reason) => true;
public bool AllowDebit(long ownerId, ushort currencyId, long amount, string reason)
=> currencyId != PremiumGems || reason != "trade"; // e.g. block trading premium currency
} -
bool AllowCredit(long characterId, ushort currencyId, long amount, string reason)Approve (or veto) crediting amount of currencyId to the owner keyed by characterId.
-
bool AllowDebit(long characterId, ushort currencyId, long amount, string reason)Approve (or veto) debiting amount of currencyId from the owner keyed by characterId.
Providers you can swap 2
Infrastructure seams. Crossplay ships a working implementation of each — replacing one changes where data lives or how it moves, never a game rule.
IEconomyLedger provider
SPI recording every committed mutation — the audit trail for the whole economy. Implement it to append to a durable ledger collection; the default NullEconomyLedger discards. Register your own with services.AddSingleton<IEconomyLedger, MyLedger>() before AddCrossplayEconomy.
-
void Append(long fromCharacterId, long toCharacterId, ushort currencyId, long amount, string reason, long utcMs)Records one committed movement of currency (called AFTER the balance change succeeds).
IEconomyStore provider
Persistence seam for balances. The default InMemoryEconomyStore keeps balances in RAM (dev/tests); a document-backed override persists them across restarts. Register your own with services.AddSingleton<IEconomyStore, MyStore>() before AddCrossplayEconomy. The service caches loaded rows, so Load is hit once per owner and Save on each committed mutation (wrap with a write-behind decorator if save frequency is a concern).
-
Dictionary<ushort, long> Load(long characterId)Loads the owner's purse, or null if the owner has never held any currency.
-
void Save(long characterId, Dictionary<ushort, long> balances)Persists the owner's full purse (the framework passes the complete post-mutation map).
-
void SavePair(long characterA, Dictionary<ushort, long> balancesA, long characterB, Dictionary<ushort, long> balancesB)Saves BOTH sides of a transfer as one unit (atomic on a durable store). Override to make the two writes a single transaction; the default calls Save twice.
Services you call 1
Crossplay implements these. Resolve them from DI and call them from your own systems.
IEconomyService
Server-side economy API — the interface GAME CODE calls to move currency (quest rewards, vendor purchases, trade payouts). Clients can only READ balances over the wire; every mutation originates here, server-side and authoritative. Resolve it from DI: [Inject] IEconomyService economy.
-
long BalanceOf(long ownerId, ushort currencyId)Current balance of currencyId for the owner keyed by ownerId (0 if none).
-
long BalanceOf(ISession session, ushort currencyId)Balance of a currency for this session, keyed automatically by the currency's CurrencyScope.
-
event Action<long> BalancesChangedRaised after any owner's balances change. The argument is the resolved KEYING id that changed (account id for an account-scoped currency, character/owner id for a character-scoped one). The Economy handler subscribes to push a fresh Balances message; game code may too.
-
void CollectBalances(long ownerId, List<CurrencyBalance> into)Snapshots one owner-row's balances into into (direct id-keyed read).
-
void CollectBalances(ISession session, List<CurrencyBalance> into)Collects ALL of this session's balances — account-scoped currencies keyed by the account id, character-scoped by the owner id — merged into one list (this is what the wire reply uses).
-
bool TryCredit(long ownerId, ushort currencyId, long amount, string reason)Adds currency to an owner (mint from the world). Vetoable by IEconomyPolicy.
-
bool TryCredit(ISession session, ushort currencyId, long amount, string reason)Credit a currency for this session, keyed automatically by the currency's CurrencyScope.
-
bool TryDebit(long ownerId, ushort currencyId, long amount, string reason)Removes currency from an owner (burn to the world). Vetoable by IEconomyPolicy.
-
bool TryDebit(ISession session, ushort currencyId, long amount, string reason)Debit a currency for this session, keyed automatically by the currency's CurrencyScope.
-
bool TryTransfer(long fromOwnerId, long toOwnerId, ushort currencyId, long amount, string reason)Atomic player-to-player transfer: the debit and credit land together or not at all.
Configuration 1
Every tunable lives in an options object — there are no magic numbers to hunt for.
EconomyOptions
Configurable economy rules (defaults here, never inline).
-
IDictionary<ushort, CurrencyScope> CurrencyScopes { get; }Per-currency scope overrides. A currency absent here uses DefaultScope.
-
CurrencyScope DefaultScope { get; set; }Scope applied to any currency without an explicit CurrencyScopes entry. Account-wide by default — the zero-config safe default (per-character is an explicit opt-in per currency), so a game gets one account purse for free and only splits out character currencies deliberately.
-
long MaxBalance { get; set; }Hard cap any single balance may reach (guards overflow-style exploits).
Wire messages 2
The protocol this piece speaks. Ids are allocated per piece so they can never collide.
BalancesRequest Client → server: "send me my balances." Carries no fields — the server resolves the requester's owner id from the authenticated session. The reply is a Balances message.
Balances Server → client: the full balance set for the requester. Sent both as the reply to a BalancesRequest and, unsolicited, as the change push whenever a mutation fires IEconomyService.BalancesChanged for that owner. Account-scoped and character-scoped currencies are merged into the one flat list (see CurrencyScope).
Unity services you inject 1
Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.
IEconomyClient
The Economy piece, client side: read-only balances for your selected character. What a currency id MEANS (gold, gems, tickets) — and every way to EARN or SPEND it — is server-side game code; the client can only look.
-
void Refresh()Requests fresh balances (the reply raises BalancesChanged).
-
long BalanceOf(ushort currencyId)The latest known amount for a currency (0 when unknown).
-
event Action<CurrencyBalance[]> BalancesChangedBalances arrived/changed (the full current set).
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.
ICrossplayWalletView
Narrow contract the presenter drives. UI-only: render calls in, user intents out.