Steamworks
Purpose
Section titled “Purpose”Optional Steam integration: identity, friends, lobbies, rich presence, overlay and achievements. All of it goes
through an engine-owned Steam service, which starts only when Steam can really run. Otherwise every wrapper is a
quiet no-op, and the game never fails because Steam is missing (CLAUDE.md: “only activate if Steam is running”).
Status (M5 ✅, with a natives limitation): the service, guards, lobby fixes, achievements persistence, the engine hook (
SteamServer, pumped every frame) and the lobby → transport handoff (connectmetadata) shipped. Steam does not run on any platform yet: nosteam_apinative library ships (see Natives), and on Apple Silicon Steamworks.NET cannot load at all. Because of that, Steam Networking Sockets (SteamSocketsTransport) and avatars as textures are designed but not implemented; both need a running Steam to develop and test against.
Lifecycle
Section titled “Lifecycle”stateDiagram-v2
[*] --> NotInitialized
NotInitialized --> InvalidAppId: TryInitialize(0)
NotInitialized --> UnsupportedPlatform: arm64 process
NotInitialized --> NativeLibraryMissing: steam_api not next to the app
NotInitialized --> SteamNotRunning: SteamAPI_InitEx → NoSteamClient
NotInitialized --> InitFailed: Packsize/DllCheck/InitEx failure, or anything thrown
NotInitialized --> Running: SteamAPI_InitEx → OK
Running --> Running: Steam.RunCallbacks() every frame
Running --> Shutdown: Steam.Shutdown()
Steam.TryInitialize(appId, writeDevAppIdFile: isDevBuild); // never throws; returns Steam.Valid// every frame, main thread:Steam.RunCallbacks(); // no-op unless Running// at exit:Steam.Shutdown();-
Steam.TryInitialize(uint appId, bool writeDevAppIdFile = false)checks, in order:- The app id is not 0.
- The process architecture is supported.
- The dev app id file, if
writeDevAppIdFileis set. - The native library loads (
NativeLibrary.TryLoadwith Steamworks.NET’s own import name). Packsize.Test()andDllCheck.Test()pass.SteamAPI.InitEx()succeeds.
On success it registers the persistent callbacks and calls
SteamUserStats.RequestCurrentStats(). Every failure ends in aSteamStatusplus aSteam.StatusMessage, logged at info level. As a last resort, any exception becomesInitFailed. -
Steam.RunCallbacks()must be pumped once per frame on the main thread. Lobby results andawaitcontinuations of the lobby calls run inside it. -
Dev app id.
writeDevAppIdFile: truewritessteam_appid.txtnext to the executable, only when it is missing or different, and setsSteamAppIdfor the process. A build started from an IDE or withdotnet runthen initializes. Never enable it in shipped builds: it bypasses Steam’s “launched through Steam” check. -
Steam.RestartAppIfNecessary(uint appId)wrapsSteamAPI_RestartAppIfNecessary: true when the game was started outside the Steam client and Steam is relaunching it, so the process must exit at once. It never throws and returns false (carry on) when Steam cannot be asked: app id 0, an unsupported platform, no native library.
Why nothing mentions Steamworks types
Section titled “Why nothing mentions Steamworks types”Steamworks.NET 2024.8.0 ships x86/x86-64-only managed assemblies (lib/ and every runtimes/*/lib/ copy is PE32+
AMD64), so in an arm64 process the runtime refuses to load them (FileLoadException). If the JIT compiles any method
that references a Steamworks.NET type, it loads that assembly, even when the branch never runs. The code is therefore
split:
| Layer | Files | Rule |
|---|---|---|
| Public facade | Steam, SteamLobby, SteamLobbyInfo, SteamAchievements, SteamOverlay, SteamRichPresence, SteamFriend | No Steamworks type in any signature, field or method body. Every member checks Steam.Valid first. |
| Native layer | Native/SteamApi.cs, Native/SteamLobbyApi.cs (internal) | Only code that touches Steamworks.NET. Members are [MethodImpl(NoInlining)] with primitive signatures. Callback handlers live in a nested class. |
The unit tests run this path on Apple Silicon: every wrapper is called and must return a default without throwing.
Key types
Section titled “Key types”| Type | Surface |
|---|---|
Steam | TryInitialize, RunCallbacks, Shutdown, Status, StatusMessage, Valid, AppId, IsPlatformSupported, NativeLibraryFileName, SteamId, Username, Branch, GetLaunchCommandLine(flag), GetFriends(), GetFriendUsername, HasDLC |
SteamStatus | NotInitialized, Running, UnsupportedPlatform, InvalidAppId, NativeLibraryMissing, SteamNotRunning, InitFailed, Shutdown |
SteamLobby | CreateLobbyAsync, JoinLobbyAsync, LeaveLobby, GetLobbyListAsync(friendsOnly), Current, Timeout (15 s), events OnLobbyUpdated, OnLobbyJoined, OnMemberChanged(lobby, user, joined) |
SteamLobbyInfo | Live lobby metadata: HostId, LobbyName, AppVersion, Country, IsAdvertising, PlayerCount, MaxPlayers, OwnerId, GetPlayers() |
SteamAchievements | IsUnlocked, Unlock and Clear (both call StoreStats), StoreStats |
SteamRichPresence | Set*, Clear, GetFriendConnect, GetFriendGroupSize, OnJoinRequested(connect) |
SteamOverlay | IsEnabled, OpenWeb, OpenStore, InviteFriendToGame(connect), InviteFriendToRemotePlay |
SteamFriend | readonly struct (SteamId, Username) |
Without Steam:
- async lobby calls return
nullor an empty list; - getters return
0,""or empty; - setters and overlay calls do nothing;
- argument validation still throws, because that is a caller bug.
Lobbies
Section titled “Lobbies”sequenceDiagram
participant Game
participant L as SteamLobby
participant API as SteamLobbyApi
participant SM as SteamMatchmaking
Game->>L: CreateLobbyAsync(name, max, appVersion?, friendsOnly, ct)
L->>API: CreateLobbyAsync
API->>SM: CreateLobby → SteamAPICall_t, bound to CallResult<LobbyCreated_t>
SM-->>API: result (inside Steam.RunCallbacks)
alt OK
API-->>L: lobby id
L->>L: Current = lobby#59; set HostId/LobbyName/AppVersion?/Country#59; rich presence connect + group
else failure / I/O failure / Timeout elapsed
API-->>L: 0 (logged)
L-->>Game: null
end
Note over Game,SM: overlay invite accepted (GameLobbyJoinRequested_t) → LeaveLobby + JoinLobbyAsync(invite's lobby)
JoinLobbyAsync binds CallResult<LobbyEnter_t> to the JoinLobby handle. It succeeds only for
k_EChatRoomEnterResponseSuccess; then it sets Current and raises OnLobbyJoined. GetLobbyListAsync(false) uses
CallResult<LobbyMatchList_t> with a result-count filter of 60. The friends-only path reads the lobbies of immediate
friends who are playing this app id. Timeouts log and return null or empty. A CancellationToken cancels the wait
with OperationCanceledException.
Natives
Section titled “Natives”| Platform | Steamworks.NET managed (NuGet 2024.8.0) | steam_api native |
|---|---|---|
| win-x64 | ✅ runtimes/win-x64/lib (imports steam_api64) | ❌ not shipped (steam_api64.dll) |
| linux-x64 | ✅ runtimes/linux-x64/lib (imports steam_api) | ❌ not shipped (libsteam_api.so) |
| osx-x64 | ✅ runtimes/osx-x64/lib (imports steam_api) | ❌ not shipped (libsteam_api.dylib) |
| osx-arm64 (and any arm64 process) | ❌ x86-64-only assembly cannot load → UnsupportedPlatform | n/a |
The NuGet package ships no native libraries. They come from the Steamworks SDK (redistributable_bin), which
needs a Steamworks partner login, so they are not in the repo.
Enabling Steam on a machine: copy the SDK file into MainframeEngine/runtimes/<rid>/native/. The engine csproj
copies every file there next to the app (see Networking → Platform), and
TryInitialize finds it. Check Valve’s SDK licence before committing redistributables.
Apple Silicon needs either an x64 .NET runtime under Rosetta, or a Steamworks.NET build with AnyCPU or arm64 assemblies. Upgrading the package is a dependency decision and must be discussed first.
Engine hook
Section titled “Engine hook”Set EngineOptions.SteamAppId and Engine.OnLoad registers a SteamServer (an IFrameServer):
Steam.TryInitialize when it is registered, Steam.RunCallbacks from IFrameServer.Process — which
SceneTree.Tick calls once per frame for every frame server, after process, deferred frees and transform sync — and
Steam.Shutdown when the servers are disposed. It is inert (Started false) when Steam cannot start.
EngineOptions.SteamWriteDevAppIdFile passes writeDevAppIdFile through.
Project settings
Section titled “Project settings”Games set Steam in project.mfproj (steam section; the editor’s Project Settings › Steamworks), and GameHost
applies it:
| Setting | Default | Effect |
|---|---|---|
appId (App ID) | 0 | The full game’s app id. 0 leaves Steam off. |
demoAppId (Demo App ID) | 0 | The demo’s own app id (a Steam demo is a separate app), used when the build is a demo (isDemo, demo builds). 0 leaves Steam off in the demo. |
devAppIdFile (Run Outside Steam in Development) | true | Development runs — started by the editor’s Play (--editor-port) or a Debug build (GameHost.IsDevelopmentRun) — set EngineOptions.SteamWriteDevAppIdFile, so Steam starts without the game being launched by Steam. Never in Release builds run on their own. |
restartThroughSteam (Relaunch Through Steam) | false | Shipped runs (not development) call Steam.RestartAppIfNecessary in GameHost.Run before the window opens and exit when Steam relaunches the game, so ownership checks and the overlay always apply. |
ProjectSettings.SteamAppId (read-only) is the id the build uses: demoAppId for a demo, else appId.
Lobby → transport handoff
Section titled “Lobby → transport handoff”The host advertises where its server can be reached in the lobby’s connect metadata
(SteamLobbyInfo.ConnectAddress, key SteamLobbyInfo.ConnectKey; or CreateLobbyAsync(…, connectAddress:)): a
;-separated NetworkAddress list, best first.
// Hostmp.Host(7777, maxClients: 8);var connect = NetworkAddress.FormatList(NetworkAddress.Steam(Steam.SteamId), NetworkAddress.Enet(NetworkNode.LocalIp, 7777));var lobby = await SteamLobby.CreateLobbyAsync("My game", 8, connectAddress: connect);
// Member (after JoinLobbyAsync or an accepted invite, SteamLobby.OnLobbyJoined)if (!mp.TryConnect(lobby.ConnectAddress)) Log.Warning("no usable address");MultiplayerApi.TryConnect gives the string to a TransportSelector, which opens the first address whose transport
works on this machine: steam: → SteamSocketsTransport (always unavailable for now, so it falls through), enet: →
EnetTransport. The selection logic is tested end to end over LoopbackTransport (loopback: addresses through a
LoopbackTransportFactory), since Steam cannot run here.
Known issues / not done
Section titled “Known issues / not done”- Steam Networking Sockets.
SteamSocketsTransportis a stub (its intended mapping is documented in the type); implementing it needs the natives. Until thensteam:addresses are skipped and ENet is used. - Avatars (
SteamFriends.GetLargeFriendAvatar→SteamUtils.GetImageRGBA→ a Vulkan texture through the M3 GPU resource path, thenUiServer.RegisterTexturefor the UI) are not implemented: without a running Steam there is no image to load or test. The old commented-out UnitySteamAvatarandSteamRemotePlayfiles were deleted. - Member updates.
OnLobbyUpdatedreports lobby-level metadata updates only; member data updates are ignored. - Lobby continuations run inside
RunCallbackson the main thread. A continuation after a timeout runs on a thread-pool thread.
Related docs
Section titled “Related docs”Networking · Build & platforms · ADR 0043: lobby connect strings · ADR 0044: Steam features without natives