ZOA Platform
One provider-neutral surface for auth, profiles, achievements, leaderboards, stats, friends, cloud saves, inventory, Workshop, DLC, overlay, and lobbies.
Platform is the seam between your game and whatever store it happens to be running on. Game code calls PlatformServices, which forwards to whichever IPlatformProvider is currently active. Nothing above this line knows whether the answer came from Steam, from an in-memory mock, or from nothing at all.
The provider surface is wide but flat. One IPlatformProvider exposes thirteen service properties, each a small interface: Auth, Profiles, Achievements, Leaderboards, Stats, Friends, Inventory, CloudSave, Workshop, Apps, Overlay, Lobbies, and Events. Every asynchronous call returns a PlatformResult or PlatformResult of T rather than throwing, because on a store integration a refusal is an ordinary outcome.
Capability is declared up front. A provider publishes a PlatformCapability bitmask, and PlatformServices.Supports answers whether a feature is present before you call it. A provider that cannot do something returns Unavailable with a message instead of silently succeeding, so the fallback path is explicit at the call site.
The package depends on nothing and its core assembly is compiled with noEngineReferences, so the whole contract and the two shipped providers are plain C# that can be unit-tested without a scene. Only the two optional UI components under Runtime/Unity touch Unity at all.
Depends on (0)
Depended on by (2)
How it works
Concepts
Provider, registry, facade
Three pieces sit in a line. IPlatformProvider is the implementation. PlatformProviderRegistry is the process-wide table that holds them, keyed case-insensitively by ProviderId, with the first registered provider becoming active automatically. PlatformServices is the static facade that forwards every property and method to the active provider.
Active never returns null. When nothing has been registered it returns NullPlatformProvider.Instance, and IsRealProviderActive is how you tell the difference. That means gameplay code can call PlatformServices.Achievements.UnlockAsync in an editor session with no store client running and get an orderly Unavailable back rather than a null reference.
Unregister keeps the invariant: removing the active provider promotes another registered one, or falls back to null. ResetForTests clears the table outright, which the edit-mode tests call between cases.
Every call comes back as a result
PlatformResult carries a PlatformOperationStatus and a message; the generic PlatformResult of T adds a value. Succeeded is Status equal to Success, so a call site can branch on one property and still have the diagnostic string available for a log.
The status enumeration is the useful part: Success, Unavailable, NotInitialized, NotAuthenticated, InvalidRequest, NotFound, RateLimited, Conflict, Canceled, Faulted. Each of those implies a different response. Unavailable means the capability does not exist here and you should hide the feature. NotInitialized means you called too early. RateLimited means back off. Collapsing all of them into a boolean loses exactly the information you need to decide.
Fail refuses to construct a failure with a Success status and coerces it to Faulted, so a result cannot claim to have failed successfully.
Capabilities are a bitmask you check first
PlatformCapability is a flags enum covering Auth, Profile, Achievements, Leaderboards, Stats, Friends, RichPresence, Overlay, CloudSave, Commerce, InventoryItems, Workshop, Dlc, Lobbies, and Matchmaking.
PlatformServices.Supports tests the active provider's mask, treating None as always supported so a caller with nothing to check does not have to special-case it. Use it to decide whether to build a leaderboard screen at all, rather than building it and discovering at runtime that every call comes back Unavailable.
Capabilities are provider-reported and can be narrower than the contract. The Steam provider forwards its bridge's mask, and the reflection-only bridge reports just Auth and Profile, which is all it can do without the typed Steamworks.NET assembly present.
Two shipped providers, for two different reasons
NullPlatformProvider is the always-available fallback. It reports PlatformCapability.None, State Ready, IsSignedIn false, and answers every call with Unavailable. It exists so that no code path has to null-check the platform, and so an editor session or a CI run behaves deterministically.
MockPlatformProvider is the local-testing provider, and it fakes exactly two things: identity and lobbies. It reports Auth, Profile, Friends, Lobbies, and Matchmaking, backs identity with a configurable MockPlatformIdentity, and backs lobbies with a shared MockLobbyDirectory. Achievements, cloud save, Workshop, DLC, and overlay return Unavailable exactly as the null provider does, because a purely in-memory implementation of those would be a lie.
The mock's real purpose is single-process multiplayer testing. Register one identity as the active provider for the local client, construct a second MockPlatformProvider directly with a different identity, and share one MockLobbyDirectory between them. Both see the same lobbies, so a lobby create and join flow can be driven end to end with no store client and no second account. Peer transport is unchanged.
MockPlatformBootstrap is never auto-invoked. A production build must never silently activate the mock, so registration is an explicit call from a dev menu, a test harness, or a define-gated bootstrap.
Lobby session flow is a state machine over lobby metadata
IPlatformLobbyService stays primitive: create, query, join, leave, set joinable, set and get string metadata, list members, invite. PlatformLobbySessionService is the opinionated layer on top that turns those primitives into a pre-game flow.
It drives a PlatformLobbyStartState of Browsing, InLobby, ReadyUp, Countdown, or Starting, and it stores all of its state in lobby metadata under the keys in PlatformLobbyMetadataKeys rather than in a side channel. Required player count, password protection and hash, owner start request, ready-up activation, countdown start time and duration, and each member's ready flag all live as lobby data, which means every member sees the same truth by reading the lobby they are already in.
Passwords are compared by hash, not stored in the clear, and the hash lives in metadata under zoa.passwordHash. Ready keys are per user, built by sanitising the user id into zoa.ready.<id>.
A second block of keys carries the networking handoff: zoa.network.backend, mode, hostUserId, hostAddress, port, maxPlayers, and sessionName. Those keys are the seam where a platform lobby becomes a networking session. The lobby service stays provider-neutral, and the networking package reads the same lobby to configure a transport.
PlatformLobbySessionSnapshot is the immutable view a UI binds to: state, lobby, local user, ownership, ready counts, password protection, whether start is unlocked, whether a countdown is running and how many seconds remain, the member list, and a message. Changed fires on every transition and GameStartRequested fires once when the flow completes.
Events are one hub, republished by every provider
IPlatformEventService declares thirteen events covering overlay state, local profile changes, friends changes, achievement changes, stat stores, leaderboard readiness and score submission, inventory changes, cloud file changes, Workshop item changes, DLC installs, lobby changes, and lobby invites.
PlatformEventHub is the shared implementation. It holds the events and exposes a Publish method per event, so a provider raises platform events by calling into the hub rather than reimplementing the event surface. Every shipped provider composes one.
A UI subscribes once against IPlatformEventService and keeps working when the active provider changes, because the contract owns the event shape and no provider does.
Async with cancellation, ticked from the main loop
Every service method is a Task returning a PlatformResult and takes a CancellationToken. Store SDKs are callback-driven and latency-prone, and pretending otherwise produces either a frame hitch or a lie.
IPlatformProvider.Tick is the pump. A native SDK needs its callbacks dispatched on the main thread each frame, so a bootstrap component calls PlatformServices.Tick from Update. The Steam runtime component in the Steam package does this for you when its tick option is enabled.
InitializeAsync and ShutdownAsync bracket the provider's life, and StateChanged reports transitions through PlatformProviderState of Uninitialized, Initializing, Ready, SignedOut, Faulted, ShuttingDown, and Shutdown. A UI that binds to State rather than to a boolean gets the intermediate states for free.
In the editor
Screens
Screenshot pending
/screenshots/platform-lobby-menu.png
PlatformLobbyMenuController in play mode showing the browse list on one side and a joined lobby on the other, with member rows, ready state, and the required-player count visible.
Screenshot pending
/screenshots/platform-lobby-countdown.png
The same menu in the Countdown state, with every member ready, the countdown seconds remaining shown, and the owner's start control in its unlocked state.
Screenshot pending
/screenshots/platform-profile-hud.png
PlatformProfileHudPresenter rendered into the TopStatusBar region during play, showing the signed-in display name and avatar slot.
Setup
Workflow
- 01
Register a provider during bootstrap
Call PlatformServices.Register with the provider for the build you are making, then TrySetActive if more than one is registered. Registration must happen before anything resolves a service, because until it does every call goes to the null provider and returns Unavailable.
- 02
Initialize, then tick
Await InitializeActiveAsync once and check the result. Then call PlatformServices.Tick every frame from a bootstrap component so callback-driven SDKs can dispatch. A provider that is never ticked will look permanently pending on every asynchronous call.
- 03
Gate features on capability
Before building a leaderboard screen or a Workshop browser, test PlatformServices.Supports for the matching capability flag. It is a cheaper and clearer signal than issuing a call and interpreting an Unavailable result, and it lets the UI hide a feature instead of failing on it.
- 04
Sign in and read the profile
Await SignInAsync, then GetLocalProfileAsync. Both return a result, so a signed-out user or an unavailable provider is a branch rather than an exception. Subscribe to SignedIn and SignedOut to keep the HUD honest when the state changes underneath you.
- 05
Drive the lobby flow through the session service
Construct a PlatformLobbySessionService, either on the active provider's services through the parameterless constructor or with explicit services for a test. Create or join a lobby, call SetReadyAsync as players ready up, call TickAsync regularly to advance the countdown, and handle GameStartRequested to hand off to networking.
- 06
Test the flow without a store
Register a MockPlatformProvider through MockPlatformBootstrap for the local identity. To exercise two accounts in one process, construct a second provider directly with a different MockPlatformIdentity and pass both the same MockLobbyDirectory, so the two see each other's lobbies.
Surface
Key types
IPlatformProvider
interface
The whole store integration behind one object. Thirteen service properties, an availability and capability declaration, a lifecycle state, and Initialize, Shutdown, and Tick.
- string ProviderId { get; } / string DisplayName { get; }
- PlatformCapability Capabilities { get; }
- PlatformProviderAvailability Availability { get; }
- PlatformProviderState State { get; }
- IPlatformAuthService Auth / IPlatformProfileService Profiles / IPlatformAchievementService Achievements
- IPlatformLeaderboardService Leaderboards / IPlatformStatsService Stats / IPlatformFriendsService Friends
- IPlatformInventoryService Inventory / IPlatformCloudSaveService CloudSave / IPlatformWorkshopService Workshop
- IPlatformAppsService Apps / IPlatformOverlayService Overlay / IPlatformLobbyService Lobbies / IPlatformEventService Events
- event Action<PlatformProviderState> StateChanged
- Task<PlatformResult> InitializeAsync(CancellationToken cancellationToken = default)
- Task<PlatformResult> ShutdownAsync(CancellationToken cancellationToken = default)
- void Tick()
PlatformServices
service
The static facade game code calls. Forwards every service and lifecycle call to the active provider, and answers capability questions without a null check ever being necessary.
- static IPlatformProvider ActiveProvider { get; }
- static bool IsRealProviderActive { get; }
- static bool Supports(PlatformCapability capability)
- static void Register(IPlatformProvider provider)
- static void SetActive(string providerId) / static bool TrySetActive(string providerId)
- static bool TryResolve(string providerId, out IPlatformProvider provider)
- static IReadOnlyList<IPlatformProvider> RegisteredProviders()
- static Task<PlatformResult> InitializeActiveAsync(CancellationToken cancellationToken = default)
- static void Tick()
PlatformProviderRegistry
service
Case-insensitive provider table. The first registered provider becomes active automatically, unregistering the active one promotes another, and Active falls back to the null provider.
- static IPlatformProvider Active { get; }
- static bool IsRealProviderActive { get; } / static int Count { get; }
- static void Register(IPlatformProvider provider) / static void Unregister(string providerId)
- static void SetActive(string providerId) / static bool TrySetActive(string providerId)
- static void ResetForTests()
PlatformResult
struct
A status plus a message. Fail coerces a Success status to Faulted, so a result cannot claim to have failed successfully.
- PlatformOperationStatus Status { get; } / string Message { get; } / bool Succeeded { get; }
- static PlatformResult Success(string message = "OK")
- static PlatformResult Fail(PlatformOperationStatus status, string message)
PlatformOperationStatus
enum
Success, Unavailable, NotInitialized, NotAuthenticated, InvalidRequest, NotFound, RateLimited, Conflict, Canceled, Faulted. Distinct because each one implies a different response from the caller.
PlatformCapability
enum
Flags for Auth, Profile, Achievements, Leaderboards, Stats, Friends, RichPresence, Overlay, CloudSave, Commerce, InventoryItems, Workshop, Dlc, Lobbies, Matchmaking. Check it before building a feature, not after a call fails.
PlatformProviderState
enum
Uninitialized, Initializing, Ready, SignedOut, Faulted, ShuttingDown, Shutdown. Raised through StateChanged, so UI can show the intermediate states.
PlatformProviderAvailability
struct
Whether a provider can run here at all, with a reason. Available and Unavailable are the factories, and the reason is written to be shown to a developer diagnosing a missing SDK.
IPlatformAuthService
interface
Sign-in state and platform auth tickets. RequestAuthTicketAsync mints a ticket for a stated purpose, and ReleaseAuthTicketAsync gives it back, which matters on platforms that cap outstanding tickets.
- bool IsSignedIn { get; } / PlatformUserId LocalUserId { get; }
- event Action<PlatformUserId> SignedIn / SignedOut
- Task<PlatformResult<PlatformUserId>> SignInAsync(...)
- Task<PlatformResult<PlatformAuthTicket>> RequestAuthTicketAsync(PlatformAuthTicketPurpose purpose, ...)
- Task<PlatformResult> ReleaseAuthTicketAsync(PlatformAuthTicket ticket, ...)
IPlatformLobbyService
interface
Primitive lobby operations: create, query, join, leave, joinable, string metadata get and set, member list, invite. All flow logic sits above it.
- Task<PlatformResult<PlatformLobby>> CreateLobbyAsync(PlatformLobbyCreateRequest request, ...)
- Task<PlatformResult<IReadOnlyList<PlatformLobby>>> QueryLobbiesAsync(PlatformLobbyQuery query, ...)
- Task<PlatformResult<PlatformLobby>> JoinLobbyAsync(PlatformLobbyId lobbyId, ...)
- Task<PlatformResult> SetLobbyDataAsync(PlatformLobbyId lobbyId, string key, string value, ...)
- Task<PlatformResult<IReadOnlyList<PlatformLobbyMember>>> GetLobbyMembersAsync(PlatformLobbyId lobbyId, ...)
- Task<PlatformResult> InviteUserAsync(PlatformLobbyId lobbyId, PlatformUserId userId, ...)
IPlatformAchievementService
interface
Get, Unlock, SetProgress, and Flush. Flush is separate because most store SDKs batch stat and achievement writes and only commit on an explicit store call.
IPlatformLeaderboardService
interface
FindOrCreate with an explicit LeaderboardSortMethod and LeaderboardDisplayType, SubmitScore with an update policy and optional detail ints, and paged global score reads.
IPlatformStatsService
interface
Typed integer and floating-point stat reads and writes with a separate FlushAsync, mirroring how store SDKs actually commit.
IPlatformCloudSaveService
interface
List, read, write, and delete cloud files by name, plus a quota query. Byte arrays rather than streams, because that is the shape every store SDK exposes.
IPlatformInventoryService
interface
Store inventory items: read items and definitions, grant, trigger a drop list, consume a quantity, and exchange a set of consumed items for a set of generated ones.
IPlatformWorkshopService
interface
User-generated content: subscribed items, single item lookup, create with a visibility, update through a PlatformWorkshopItemUpdate, and subscribe or unsubscribe.
IPlatformAppsService
interface
App and DLC ownership: current app id, whether an app is subscribed, a single DLC lookup, and the installed DLC list.
IPlatformOverlayService
interface
Activates a platform overlay dialog, a store page with an explicit action, or the invite dialog for a lobby.
IPlatformEventService
interface
The thirteen platform events a UI can subscribe to once and keep bound across a provider change: overlay, profile, friends, achievements, stats, leaderboards, inventory, cloud files, Workshop, DLC, lobby changes, and lobby invites.
PlatformEventHub
class
The shared IPlatformEventService implementation. Providers compose one and raise events through its Publish methods, so the event surface is declared once.
NullPlatformProvider
class
The always-available fallback returned by the registry when nothing is registered. Capabilities None, State Ready, and every call answered Unavailable with a message.
- static readonly NullPlatformProvider Instance
MockPlatformProvider
class
In-memory provider for local multiplayer testing. Fakes identity and lobbies only, reporting Auth, Profile, Friends, Lobbies, and Matchmaking; everything it cannot honestly simulate returns Unavailable.
MockPlatformIdentity
struct
A synthetic signed-in user for the mock provider. Converts to a PlatformUserId and a PlatformUserProfile, so two identities in one process look like two distinct accounts to everything above.
- static MockPlatformIdentity Player(string userId, string displayName = null)
- PlatformUserId ToPlatformUserId() / PlatformUserProfile ToProfile()
MockLobbyDirectory
class
Process-wide in-memory lobby store behind the mock provider. Two mock providers that share a directory see the same lobbies, which is how host-and-client lobby testing runs in one process.
- static MockLobbyDirectory Shared { get; }
- event Action<PlatformLobbyChanged> Changed
- string MintLobbyId()
- PlatformLobby Create(...) / bool Join(...) / bool Leave(...) / bool SetData(...)
- void Reset()
MockPlatformBootstrap
class
Explicit registration helper for the mock provider. Never auto-invoked, because a production build must not silently activate it.
- static MockPlatformProvider Register(MockPlatformIdentity identity, bool makeActive = true, MockLobbyDirectory directory = null)
- static void Unregister()
PlatformLobbySessionService
class
The pre-game flow over the primitive lobby service: create, browse, join, ready up, countdown, start. All state lives in lobby metadata so every member reads the same truth.
- Task<PlatformResult<PlatformLobby>> CreateLobbyAsync(PlatformLobbySessionCreateOptions options, ...)
- Task<PlatformResult<IReadOnlyList<PlatformLobby>>> RefreshLobbiesAsync(...)
- Task<PlatformResult<PlatformLobby>> JoinLobbyAsync(...) / Task<PlatformResult> LeaveLobbyAsync(...)
- Task<PlatformResult> SetReadyAsync(bool ready, ...) / Task<PlatformResult> RequestStartAsync(...)
- Task TickAsync(CancellationToken cancellationToken = default)
- PlatformLobbySessionSnapshot Current { get; }
- event Action<PlatformLobbySessionSnapshot> Changed / GameStartRequested
PlatformLobbySessionSnapshot
struct
The immutable view a lobby UI binds to: start state, lobby, local user, ownership, ready count against the requirement, password protection, start unlocked, countdown state and remaining seconds, members, and a message.
PlatformLobbyStartState
enum
Browsing, InLobby, ReadyUp, Countdown, Starting. The five states the session service moves through.
PlatformLobbyMetadataKeys
class
The metadata contract. Session keys such as zoa.requiredPlayers, zoa.passwordHash, and zoa.countdownSeconds, per-user ready keys built by ReadyKey, and the zoa.network.* block that hands a lobby off to the networking session.
- static string ReadyKey(PlatformUserId userId)
PlatformUserId
struct
A provider id paired with a provider-scoped user id, so two users from different providers can never compare equal by accident.
PlatformLobby
struct
A lobby as the platform sees it: identity, owner, member count and capacity, visibility, joinability, and its metadata map.
PlatformAuthTicket
struct
A minted platform auth ticket with its payload and a handle for release. A networking auth adapter validates a joining client against that handle.
PlatformLobbyMenuController
component
UI Toolkit lobby menu bound to PlatformLobbySessionService. Provider-neutral: it works against whichever provider is active, so the Steam runtime can install it without owning it.
- static PlatformLobbyMenuController EnsureInScene(Transform parent = null)
- PlatformLobbySessionSnapshot Snapshot { get; }
- event Action<PlatformLobbySessionSnapshot> GameStartRequested
PlatformProfileHudPresenter
component
Renders the signed-in user's profile into a named HUD region, defaulting to TopStatusBar, with an avatar texture that a provider-specific presenter can supply.
- Task RefreshAsync()
- void SetAvatar(Texture2D texture)
Usage
Examples
using ZOA.Platform.Core;
// During bootstrap, before anything resolves a service.
PlatformServices.Register(provider);
PlatformServices.TrySetActive(provider.ProviderId);
var init = await PlatformServices.InitializeActiveAsync();
if (!init.Succeeded)
{
// init.Status distinguishes Unavailable (no client, hide the
// feature) from Faulted (something broke, worth reporting).
Debug.LogWarning(init.Status + ": " + init.Message);
}
// Ask before you build the screen.
if (PlatformServices.Supports(PlatformCapability.Leaderboards))
BuildLeaderboardScreen();using ZOA.Platform.Core;
var signIn = await PlatformServices.Auth.SignInAsync();
if (!signIn.Succeeded)
{
ShowOfflineBadge(signIn.Message);
return;
}
var profile = await PlatformServices.Profiles.GetLocalProfileAsync();
if (profile.Succeeded)
SetPlayerName(profile.Value.DisplayName);
// The events are declared by the contract, not by a provider, so
// this subscription survives a provider swap.
PlatformServices.Events.LocalProfileChanged += updated => SetPlayerName(updated.DisplayName);using ZOA.Platform.Core;
if (!PlatformServices.Supports(PlatformCapability.Achievements))
return;
var unlock = await PlatformServices.Achievements.UnlockAsync("FIRST_CLEAR");
if (unlock.Status == PlatformOperationStatus.NotAuthenticated)
{
// Queue it locally and retry after sign-in rather than losing it.
QueueForRetry("FIRST_CLEAR");
return;
}
await PlatformServices.Stats.SetIntStatAsync("total_clears", clears);
// Stats and achievements are batched by most SDKs. Nothing is
// committed to the store until a flush.
await PlatformServices.Stats.FlushAsync();
await PlatformServices.Achievements.FlushAsync();using ZOA.Platform.Core;
var session = new PlatformLobbySessionService();
session.Changed += snapshot =>
{
// Bind directly: state, ready count, countdown, and members are
// all on the snapshot, so the UI needs no state of its own.
RenderLobby(snapshot);
};
session.GameStartRequested += snapshot =>
{
// The lobby carries the networking handoff under the
// zoa.network.* metadata keys.
var backend = snapshot.Lobby.Metadata[PlatformLobbyMetadataKeys.NetworkBackend];
var address = snapshot.Lobby.Metadata[PlatformLobbyMetadataKeys.NetworkHostAddress];
StartNetworkedSession(backend, address);
};
await session.CreateLobbyAsync(new PlatformLobbySessionCreateOptions(
sessionName: "Evening Run",
visibility: PlatformLobbyVisibility.Public,
maxPlayers: 4,
requiredPlayersToStart: 2,
password: null,
countdownSeconds: 10));
await session.SetReadyAsync(true);
// Call regularly so the countdown advances and remote changes land.
await session.TickAsync();using ZOA.Platform.Core;
// Share one directory so both providers see the same lobbies.
var directory = MockLobbyDirectory.Shared;
// The local client's identity becomes the active provider.
var host = MockPlatformBootstrap.Register(
MockPlatformIdentity.Player("p1", "Alice"),
makeActive: true,
directory: directory);
// The second identity is constructed directly. The registry keys by
// ProviderId, so only one mock can be the active provider at a time.
var guest = new MockPlatformProvider(
MockPlatformIdentity.Player("p2", "Bob"),
directory);
await host.InitializeAsync();
await guest.InitializeAsync();
var created = await host.Lobbies.CreateLobbyAsync(request);
var joined = await guest.Lobbies.JoinLobbyAsync(created.Value.LobbyId);
// Tear down explicitly; the mock must never survive into a build.
MockPlatformBootstrap.Unregister();
MockLobbyDirectory.Shared.Reset();Read this
Notes and caveats
See also