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: Steam runs on Windows x64, Linux x64 and macOS (x64 and Apple Silicon) through Facepunch.Steamworks 2.4.1, the same setup as Speed Golf (ADR 0189). The
steam_apinatives ship with the engine and its NuGet package. Steam Networking Sockets (SteamSocketsTransport) and avatars as textures are still to do.
Lifecycle
Section titled “Lifecycle”stateDiagram-v2
[*] --> NotInitialized
NotInitialized --> InvalidAppId: TryInitialize(0)
NotInitialized --> UnsupportedPlatform: not win-x64 / linux-x64 / osx
NotInitialized --> NativeLibraryMissing: steamworks/ or steam_api not next to the app
NotInitialized --> SteamNotRunning: SteamClient.Init → NoSteamClient
NotInitialized --> InitFailed: any other Init failure, or anything thrown
NotInitialized --> Running: SteamClient.Init succeeded
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 platform is supported (
Steam.IsPlatformSupported: Windows x64, Linux x64, macOS x64 or arm64). - This OS’s
MainframeEngine.Steamworksbuild is insteamworks/(see Layers). - The dev app id file, if
writeDevAppIdFileis set. SteamClient.Init(appId, asyncCallbacks: false)succeeds. Before it,SteamAppIdandSteamGameIdare set in the native environment too (below).
On success it subscribes the lobby, invite and rich-presence events and routes exceptions thrown inside Facepunch’s callback dispatch (
Dispatch.OnException) toLog.Error. 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. Facepunch’s own callback loop is off (asyncCallbacks: false): it pumped callbacks a second time in Speed Golf. -
Steam.Shutdown()leaves the current lobby and clears rich presence beforeSteamClient.Shutdown(Facepunch throws on the user’s SteamID afterwards), then removes the events again: they are static, and a laterTryInitializemust not subscribe them twice. -
App id outside Steam.
SteamClient.Init(appId)setsSteamAppIdin .NET’s environment. On macOS and Linux that never reaches the nativegetenvsteam_api reads, and init fails with “No appID found” (Speed Golf, 2026-09-25), so the shim also calls libcsetenv. Steam therefore starts the same way on all three OSes, also outside the Steam client and without asteam_appid.txt. UserestartThroughSteam(below) when a store build must run through Steam. -
Dev app id.
writeDevAppIdFile: truewritessteam_appid.txtnext to the executable, only when it is missing or different, and setsSteamAppIdfor the process. Never enable it in shipped builds: it bypasses Steam’s “launched through Steam” check.build/package-game.shrefuses a publish folder that contains the file. -
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. -
Never set
Dispatch.OnDebugCallback. It crashed Speed Golf with SIGBUS, with async callbacks on or off.
Layers
Section titled “Layers”Facepunch ships two managed builds: Facepunch.Steamworks.Win64.dll (Windows) and Facepunch.Steamworks.Posix.dll
(macOS, Linux). They have different assembly names, import different library names (steam_api64, libsteam_api)
and pack Steam’s structs differently (8 and 4 bytes), so neither works everywhere. Speed Golf compiles once per
platform (Godot sets a define per export); the engine ships one MainframeEngine.dll for every RID, so it puts a shim
between itself and Facepunch:
| 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. |
| Adapter | Native/SteamApi.cs, Native/SteamLobbyApi.cs (internal) | The only engine code that calls the shim. Members are [MethodImpl(NoInlining)] with engine or primitive types, so compiling a caller never loads the shim. Maps results to SteamStatus/SteamFriend, adds lobby timeouts. |
| Shim | MainframeEngine.Steamworks/Src/ | The only code that says using Steamworks;. Internal API (InternalsVisibleTo the engine), primitive types, no engine reference: logging and events go out through delegates the adapter sets. Built twice under one assembly name: Win64/ against Facepunch’s Win64 build, Posix/ against its Posix build. |
| Facepunch | Plugins/Facepunch.Steamworks/ | Vendored 2.4.1 (net6.0), not modified. |
How the right build loads. The engine compiles against the Posix shim, but neither shim is copied next to the app
or listed in its deps.json (Private="false"). Both go, with their Facepunch assembly, to
<output>/steamworks/win64/ and <output>/steamworks/posix/; a build or publish with a RID gets only that
platform’s folder. Before the first adapter call, SteamworksLoader.Register() hooks
AssemblyLoadContext.Default.Resolving: the runtime’s own probing misses MainframeEngine.Steamworks, and the
handler loads it and Facepunch.Steamworks.* from this OS’s folder. The shim then sets a DllImportResolver on
Facepunch’s assembly and its own (Speed Golf’s SteamNativeLoader): steam_api64 / libsteam_api load from the app
directory, and otherwise the default probing finds them (a NuGet package game’s RID-less build keeps them in
runtimes/<rid>/native, listed in deps.json).
Calls Facepunch keeps internal go through the steam_api flat API in the shim (SteamworksNative, LibraryImport
against the same library), never reflection: the connect-string invite dialog and the Remote Play Together invite
dialog (SteamAPI_ISteamFriends_* on SteamAPI_SteamFriends_v017()).
The unit tests (SteamTests) run the real path on every CI OS: TryInitialize(480) must load the shim, this OS’s
Facepunch build and steam_api and reach SteamNotRunning (CI has no Steam client), or Running on a developer
machine with Steam open. The wrappers’ no-op path is tested with app id 0.
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) |
Steam.GetFriends() returns immediate friends (Facepunch’s SteamFriends.GetFriends()).
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 FP as Facepunch SteamMatchmaking
Game->>L: CreateLobbyAsync(name, max, appVersion?, friendsOnly, ct)
L->>API: CreateLobbyAsync (timeout, ct)
API->>FP: CreateLobbyAsync(max) → Lobby? (created invisible)
FP-->>API: result (inside Steam.RunCallbacks)
alt OK
API->>FP: SetPublic / SetFriendsOnly, SetJoinable(true)
API-->>L: lobby id
L->>L: Current = lobby#59; set HostId/LobbyName/AppVersion?/Country#59; rich presence connect + group
else failure / Timeout elapsed
API-->>L: 0 (logged)
L-->>Game: null
end
Note over Game,FP: overlay invite accepted (OnGameLobbyJoinRequested) → LeaveLobby + JoinLobbyAsync(invite's lobby)
JoinLobbyAsync awaits Facepunch’s JoinLobbyAsync, which succeeds only for a successful enter response; then it
sets Current and raises OnLobbyJoined. GetLobbyListAsync(false) runs SteamMatchmaking.LobbyList with at most 60
results. 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. Member events come
from Facepunch’s OnLobbyMemberJoined (joined) and OnLobbyMemberLeave/Disconnected/Kicked/Banned (left).
Natives
Section titled “Natives”| Platform | Facepunch build (steamworks/…) | steam_api native (MainframeEngine/runtimes/<rid>/native) |
|---|---|---|
| win-x64 | ✅ win64/ (Facepunch.Steamworks.Win64.dll) | ✅ steam_api64.dll |
| linux-x64 | ✅ posix/ (Facepunch.Steamworks.Posix.dll) | ✅ libsteam_api.so |
| osx-x64, osx-arm64 | ✅ posix/ | ✅ libsteam_api.dylib (universal, in both RID folders) |
| Windows/Linux arm64, 32-bit | — | ❌ none in the SDK → UnsupportedPlatform |
The natives are Valve’s Steamworks SDK redistributable_bin files that Facepunch.Steamworks 2.4.1 was built
against (the SDK’s redistribution terms allow shipping them with a game; Facepunch’s and Steamworks.NET’s public
repositories carry them too). They are vendored, not built by natives.yml, and not in Native/natives.lock
(natives-lock.sh ignores files it has no component for). Refresh them only together with
Plugins/Facepunch.Steamworks: the flat-API interface versions must match. The engine csproj copies them like the
other natives; the NuGet package carries them in runtimes/<rid>/native and the Facepunch builds in
mainframe/steamworks/, which its buildTransitive targets copy into the game’s output.
Player builds. build/package-game.sh (in the package: tools/package-game.sh, found through
MainframePackageGameScript; see Release → Games) keeps only the RID’s steam_api and
steamworks/ folder, and fails if a steam_appid.txt is in the publish output. macOS Developer ID signing signs
libsteam_api.dylib with the other Mach-O files.
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 (still a stub, always unavailable, so it falls through),
enet: → EnetTransport. The selection logic is tested end to end over LoopbackTransport (loopback: addresses
through a LoopbackTransportFactory), so it needs no Steam client.
Known issues / not done
Section titled “Known issues / not done”- Steam Networking Sockets.
SteamSocketsTransportis a stub (its intended mapping is documented in the type). The natives ship now, so it can be built on Facepunch’s relay sockets (SocketManager/ConnectionManager, as Speed Golf does; #57); testing a join needs two Steam accounts or machines. Until thensteam:addresses are skipped and ENet is used. - Avatars (Facepunch’s
SteamFriends.GetLargeAvatarAsync→ RGBA → a Vulkan texture through the M3 GPU resource path, thenUiServer.RegisterTexturefor the UI) are not implemented yet (#58). 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 · ADR 0189: Facepunch.Steamworks