ZOA Platform Steam
The Steam provider for the ZOA Platform API, behind a bridge that degrades cleanly when Steamworks.NET is absent.
This package implements IPlatformProvider for Steam. It contributes no new contracts: everything it exposes is already declared in com.zoa.platform. A title that later adds a second store adds another provider and leaves gameplay code alone.
The implementation is split across three assemblies. ZOA.Platform.Steam holds SteamPlatformProvider and the ISteamClientBridge seam and is compiled with noEngineReferences, so the whole provider is plain C# that unit tests can drive with a fake bridge. ZOA.Platform.Steam.SteamworksNet holds the typed Steamworks.NET implementation and only compiles when that package is present. ZOA.Platform.Steam.Unity holds the two MonoBehaviours that make it ergonomic to drop into a scene.
The bridge seam keeps the package shippable in a project that has never installed Steamworks.NET. The provider always constructs, always registers, and answers with a status rather than a missing-assembly error. Capability breadth then depends on which bridge got created: the typed bridge reports the full Steam feature set, and the reflection fallback reports only auth and profile.
Depends on (1)
Depended on by (1)
How it works
Concepts
The provider is a thin adapter over one bridge
SteamPlatformProvider implements IPlatformProvider and every one of the thirteen platform service interfaces on itself, forwarding each call to an ISteamClientBridge. It owns a PlatformEventHub, hooks the bridge's events into it once, and tracks the PlatformProviderState transitions that StateChanged reports.
Capabilities and Availability are both read straight off the bridge, so the provider reports what is reachable instead of advertising the full Steam surface and failing later.
Constructing it takes a SteamPlatformOptions and, optionally, a bridge. The single-argument constructor builds a SteamNativeClientBridge. Tests and custom native integrations use the two-argument one.
One seam, two implementations
ISteamClientBridge is the whole Steam surface expressed in provider-neutral types. It takes SteamPlatformOptions on Initialize, exposes Shutdown and Tick, and then mirrors each platform service as a method returning a PlatformResult. It also declares the thirteen change events the provider republishes onto its hub.
SteamworksNetClientBridge is the real implementation. It references Steamworks.NET directly, registers typed Callback handlers for GameOverlayActivated_t, PersonaStateChange_t, LobbyChatUpdate_t, LobbyDataUpdate_t, LobbyInvite_t, and DlcInstalled_t, and reports the full capability set from Auth and Profile through Achievements, Leaderboards, Stats, Friends, RichPresence, Overlay, CloudSave, Commerce, InventoryItems, Workshop, Dlc, Lobbies, and Matchmaking.
SteamNativeClientBridge is the fallback. It reaches Steamworks.NET entirely through reflection, reports Availability with a reason when the assembly is missing, and declares only Auth and Profile as capabilities. Its events are declared as empty add and remove accessors, because it cannot register real Steam callbacks without the typed API. Operations that need the typed bridge return Unavailable with a message that names the installer menu path rather than failing obscurely.
Assembly gating decides which bridge exists
ZOA.Platform.Steam.SteamworksNet carries a versionDefine on com.rlabrecque.steamworks.net that emits ZOA_STEAMWORKS_NET_AVAILABLE, and a defineConstraint requiring that same define. When the Steamworks.NET package is not in the project the define is never emitted, the assembly is skipped entirely, and SteamworksNetClientBridge does not exist in the build at all despite its unguarded using Steamworks statement.
SteamPlatformRuntime resolves the typed bridge by name through Type.GetType, looking for ZOA.Platform.Steam.SteamworksNetClientBridge in ZOA.Platform.Steam.SteamworksNet. When the type is present it constructs it; when it is absent, or when construction throws, it logs and falls back to SteamNativeClientBridge.
The gating shape differs from the one the networking backends use. Steamworks.NET ships as a UPM package with a real id, so a versionDefine can target it and no user opt-in toggle is needed. FishNet ships as an Asset Store package with no UPM id, which is why that integration needs two hand-managed defines instead.
Options and the local launch problem
SteamPlatformOptions carries the App ID plus three behavioural settings. RequireSteamClient, on by default, makes initialization fail with Unavailable when the Steam client is not running rather than proceeding into an undefined state. ServerAuthIdentity is the identity or audience string passed to native auth-ticket calls. AuthTicketTtl, ten minutes by default, is the provider-neutral local expiry stamped onto a minted ticket.
SteamPlatformRuntime solves a practical local-development problem before any of that runs. A build or editor session launched outside Steam has no App ID context, so the component writes steam_appid.txt next to the project root in the editor, or next to the player executable in a standalone build, before SteamAPI initialization. It skips the write when the file already contains the right value, and it warns rather than throwing when the directory is not writable.
The default App ID is 480, the Spacewar test app. That is correct for local integration testing and wrong for anything you ship, which is the first field to change in a real project.
Scene bootstrap with explicit lifecycle choices
SteamPlatformRuntime runs at execution order minus 9700 so the provider is registered before gameplay systems resolve anything in Start. Every lifecycle decision is a serialized flag: register on Awake, initialize on Start, make active after registration, keep alive across scene loads, unregister on destroy, and tick the provider from Update.
Initialize on Start defaults to off. A title that wants an explicit boot or login flow calls InitializeProviderAsync itself and drives its progress UI from the component's IsInitializing and LastInitializeResult.
EnsureRegistered is idempotent and cooperative. When a Steam provider is already registered and no bridge override was set, it adopts that instance and records that it does not own it. HandleDestroy then unregisters only a provider this component actually created, and only when it is still the registered one, so two bootstrap objects in a scene cannot tear down each other's provider.
SetBridgeOverride must be called before EnsureRegistered and throws if a provider already exists. The ordering is enforced in code, because a bridge swapped in after construction would silently go unused.
The lobby UI Steam installs stays provider-neutral
When the Steam provider is the active one, SteamPlatformRuntime can install two pieces of UI. PlatformLobbyMenuController comes from com.zoa.platform and is entirely provider-neutral: it binds to PlatformLobbySessionService, which binds to whichever provider is active. Steam only switches it on.
SteamProfileAvatarHudPresenter is the Steam-specific half. It supplies the signed-in user's Steam avatar image into the profile HUD region that PlatformProfileHudPresenter renders, refreshing on an interval.
Both installations are gated on the active provider actually being Steam, so a scene that carries the runtime component but resolves to another provider does not get Steam UI grafted onto it.
Auth tickets are the bridge to networking
RequestAuthTicketAsync mints a Steam session ticket for a stated PlatformAuthTicketPurpose and returns a PlatformAuthTicket carrying the payload and a handle. ReleaseAuthTicket gives the handle back, which matters because Steam caps outstanding tickets per session.
The networking package's Steam auth adapter validates that ticket before admitting a joining peer. The two packages never reference each other: platform mints the ticket, the connect token carries an opaque payload, and the auth adapter validates it. The shape is provider-neutral, so another store's adapter slots into the same admission flow.
ServerAuthIdentity in the options is the audience string threaded through to the native call, so a dedicated server can require tickets minted for it specifically rather than accepting any ticket the client happens to hold.
In the editor
Screens
Screenshot pending
/screenshots/platform-steam-runtime-inspector.png
The component in the inspector showing the Steam block with App ID and client requirement, the Lifecycle block with the register, initialize, active, and tick toggles, and the Platform UI block.
Screenshot pending
/screenshots/platform-steam-install-prompt.png
The install dialog as it appears on editor load in a project without Steamworks.NET, with the Install and Not Now buttons visible.
Screenshot pending
/screenshots/platform-steam-profile-hud.png
A play-mode capture with SteamProfileAvatarHudPresenter active, showing the signed-in Steam persona name and avatar in the profile HUD region.
Setup
Workflow
- 01
Install Steamworks.NET
Accept the prompt the installer raises on editor load, or run Tools/ZOA/Advanced/Maintain/Install Steamworks.NET. It adds the pinned package com.rlabrecque.steamworks.net at version 2025.163.0 from its git URL. Once present, the versionDefine emits ZOA_STEAMWORKS_NET_AVAILABLE and the typed bridge assembly compiles.
- 02
Drop the runtime into a bootstrap scene
Add a SteamPlatformRuntime component. Set the App ID to your real one: the default of 480 is the Spacewar test app and is only correct for local integration testing. Decide whether the Steam client is required, which fails initialization cleanly when Steam is not running rather than proceeding half-configured.
- 03
Choose your lifecycle
Leave register on Awake enabled so gameplay can resolve the provider in Start. Leave initialize on Start disabled if you want an explicit login flow and call InitializeProviderAsync yourself, binding UI to IsInitializing and LastInitializeResult. Leave tick in Update enabled unless you are pumping the provider from your own loop.
- 04
Talk to Steam through PlatformServices
Once registered and active, all gameplay code goes through PlatformServices. Nothing in your game should reference SteamPlatformProvider or a Steamworks type directly. Check PlatformServices.Supports before building a feature, because the capability mask narrows when only the reflection bridge is available.
- 05
Mint a ticket for the join flow
Call RequestAuthTicketAsync before joining or hosting a session, carry the payload in the networking connect token, and release the handle when the session ends. Set ServerAuthIdentity in the options when a dedicated server needs tickets minted specifically for it.
- 06
Test without Steam
Call SetBridgeOverride with a fake ISteamClientBridge before EnsureRegistered, or construct SteamPlatformProvider directly with the fake. Because the provider assembly has no engine references, the whole flow can run in an edit-mode test with no scene and no Steam client.
Surface
Key types
SteamPlatformProvider
class
The Steam IPlatformProvider. Implements all thirteen platform service interfaces on itself and forwards each to an ISteamClientBridge, republishing bridge events through a PlatformEventHub.
- SteamPlatformProvider(SteamPlatformOptions options)
- SteamPlatformProvider(SteamPlatformOptions options, ISteamClientBridge bridge)
- string ProviderId => PlatformProviderIds.Steam
- PlatformCapability Capabilities { get; } (read from the bridge)
- PlatformProviderAvailability Availability { get; } (read from the bridge)
- PlatformProviderState State { get; } / event Action<PlatformProviderState> StateChanged
- Task<PlatformResult> InitializeAsync(...) / ShutdownAsync(...) / void Tick()
ISteamClientBridge
interface
The whole Steam surface expressed in provider-neutral types, so the provider never touches a Steamworks type. Substituting a fake here is how the provider is unit-tested without a Steam client.
- PlatformProviderAvailability Availability { get; } / PlatformCapability Capabilities { get; }
- bool IsInitialized { get; } / bool IsSignedIn { get; }
- PlatformResult Initialize(SteamPlatformOptions options) / PlatformResult Shutdown() / void Tick()
- PlatformResult<SteamUserSnapshot> GetLocalUser()
- Task<PlatformResult<SteamAuthTicket>> RequestAuthTicketAsync(PlatformAuthTicketPurpose purpose, ...)
- PlatformResult ReleaseAuthTicket(SteamAuthTicketHandle handle)
- Achievement, leaderboard, stat, friends, inventory, cloud, Workshop, app, overlay, and lobby methods mirroring the platform services
- Thirteen change events republished by the provider
SteamworksNetClientBridge
class
The typed bridge. References Steamworks.NET directly, registers Callback handlers for overlay, persona, lobby chat, lobby data, lobby invite, and DLC install, and reports the full Steam capability set.
SteamNativeClientBridge
class
The reflection fallback. Reports Availability with a reason when Steamworks.NET is absent, declares only Auth and Profile, and returns Unavailable with a message naming the installer menu path for anything that needs the typed bridge.
SteamPlatformOptions
class
Provider configuration. App ID plus whether the Steam client is required, the server auth identity threaded into ticket calls, and the local auth-ticket lifetime.
- static SteamPlatformOptions ForApp(uint appId)
- uint AppId { get; }
- bool RequireSteamClient { get; set; } (default true)
- string ServerAuthIdentity { get; set; }
- TimeSpan AuthTicketTtl { get; set; } (default 10 minutes)
SteamUserSnapshot
struct
A Steam user as the bridge sees it: SteamId, persona name, avatar URL, and country code. Converts into the provider-neutral types so nothing above the bridge handles a raw Steam id.
- PlatformUserId ToPlatformUserId()
- PlatformUserProfile ToPlatformProfile()
SteamAuthTicket
struct
A minted session ticket: the SteamId it was issued for, the payload bytes, and a SteamAuthTicketHandle for release.
SteamAuthTicketHandle
struct
The releasable handle for an outstanding ticket. IsValid is false for the zero handle, which an unminted or already-released ticket reports.
SteamPlatformRuntime
component
The scene bootstrap. Writes steam_appid.txt for local launches, registers the provider at execution order minus 9700, optionally initializes and ticks it, and installs the platform lobby menu and Steam avatar presenter when Steam is the active provider.
- IPlatformProvider EnsureRegistered()
- Task<PlatformResult> InitializeProviderAsync(CancellationToken cancellationToken = default)
- void TickProvider() / void HandleDestroy()
- void SetBridgeOverride(ISteamClientBridge bridge)
- SteamPlatformOptions BuildOptions()
- IPlatformProvider Provider { get; } / bool OwnsProvider { get; }
- bool IsInitializing { get; } / PlatformResult LastInitializeResult { get; }
SteamProfileAvatarHudPresenter
component
Feeds the signed-in user's Steam avatar into the provider-neutral profile HUD region, refreshing on a configurable interval. The Steam-specific half of the profile display.
SteamworksNetDependencyInstaller
class
Editor-side dependency management for the pinned Steamworks.NET package. Detects installation three ways and installs from the pinned git URL through the Unity package client.
- const string PackageName = "com.rlabrecque.steamworks.net"
- const string PackageVersion = "2025.163.0"
- static bool IsSteamworksNetInstalled()
- static void InstallSteamworksNet() / static void CheckSteamworksNetDependency()
- static bool ShouldOfferInstall(bool isInstalled, bool isBatchMode, bool promptAlreadyShown, bool installInProgress)
- static bool IsInstallInProgress { get; }
Usage
Examples
using ZOA.Platform.Core;
using ZOA.Platform.Steam;
var options = SteamPlatformOptions.ForApp(480);
options.RequireSteamClient = true;
options.ServerAuthIdentity = "zoa-dedicated";
options.AuthTicketTtl = System.TimeSpan.FromMinutes(10);
var provider = new SteamPlatformProvider(options);
PlatformServices.Register(provider);
PlatformServices.TrySetActive(PlatformProviderIds.Steam);
var init = await provider.InitializeAsync();
if (!init.Succeeded)
{
// Unavailable means Steamworks.NET is missing or the client is
// not running. Neither is an exception; both are a UI state.
ShowSteamUnavailable(init.Message);
}
// Callback-driven SDK: pump it every frame or nothing completes.
void Update() => PlatformServices.Tick();using ZOA.Platform.Core;
// The typed bridge reports the full Steam surface. The reflection
// fallback reports only Auth and Profile, so a project without
// Steamworks.NET installed correctly hides the rest.
if (PlatformServices.Supports(PlatformCapability.Workshop))
BuildWorkshopBrowser();
if (PlatformServices.Supports(PlatformCapability.CloudSave))
{
var quota = await PlatformServices.CloudSave.GetQuotaAsync();
if (quota.Succeeded)
ShowQuota(quota.Value);
}
// Availability is the coarser question: can this provider run here
// at all, and if not, why.
var availability = PlatformServices.Availability;
if (!availability.IsAvailable)
Debug.LogWarning(availability.Reason);using ZOA.Platform.Core;
var ticket = await PlatformServices.Auth.RequestAuthTicketAsync(
PlatformAuthTicketPurpose.Server);
if (!ticket.Succeeded)
{
ShowJoinFailed(ticket.Message);
return;
}
// The payload travels as the opaque part of the networking connect
// token; the auth adapter on the authority side validates it.
JoinSessionWithEvidence(ticket.Value);
// Steam caps outstanding tickets, so release when the session ends.
await PlatformServices.Auth.ReleaseAuthTicketAsync(ticket.Value);using ZOA.Platform.Core;
using ZOA.Platform.Steam;
// The provider assembly has no engine references, so this whole
// flow runs in an edit-mode test with no scene and no Steam client.
var bridge = new FakeSteamClientBridge
{
AvailabilityResult = PlatformProviderAvailability.Available("Fake bridge."),
CapabilityMask = PlatformCapability.Auth | PlatformCapability.Lobbies,
};
var provider = new SteamPlatformProvider(SteamPlatformOptions.ForApp(480), bridge);
await provider.InitializeAsync();
Assert.AreEqual(PlatformProviderState.Ready, provider.State);
Assert.IsTrue((provider.Capabilities & PlatformCapability.Lobbies) != 0);
// On a scene component, the same override has to precede
// EnsureRegistered or it throws: a bridge swapped in after the
// provider exists would silently not be used.
runtime.SetBridgeOverride(bridge);
runtime.EnsureRegistered();using ZOA.Platform.Steam.Editor;
// Detects a registered package, a manifest declaration, or a loaded
// assembly, so a git or local override still counts as installed.
if (!SteamworksNetDependencyInstaller.IsSteamworksNetInstalled())
{
// Guarded so batch-mode CI is never blocked on a dialog and the
// prompt appears at most once per editor session.
if (SteamworksNetDependencyInstaller.ShouldOfferInstall(
isInstalled: false,
isBatchMode: Application.isBatchMode,
promptAlreadyShown: false,
installInProgress: SteamworksNetDependencyInstaller.IsInstallInProgress))
{
SteamworksNetDependencyInstaller.InstallSteamworksNet();
}
}Tooling
Editor tools
Install Steamworks.NET
Tools/ZOA/Advanced/Maintain/Install Steamworks.NET
Adds the pinned com.rlabrecque.steamworks.net package at 2025.163.0 from its git URL through the Unity package client. The menu item disables itself while an install is already running.
Check Steamworks.NET Dependency
Tools/ZOA/Advanced/Maintain/Check Steamworks.NET Dependency
Reports whether Steamworks.NET is reachable, detecting a registered package, a manifest declaration, or a loaded assembly, and offers the install when it is not.
First-load install prompt
An InitializeOnLoad hook offers the install once per editor session when the package is missing. It is suppressed in batch mode and while an install is in progress, so CI is never blocked on a dialog.
Read this
Notes and caveats
See also