Skip to content

Steamworks

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_api natives ship with the engine and its NuGet package. Steam Networking Sockets (SteamSocketsTransport) and avatars as textures are still to do.

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:

    1. The app id is not 0.
    2. The platform is supported (Steam.IsPlatformSupported: Windows x64, Linux x64, macOS x64 or arm64).
    3. This OS’s MainframeEngine.Steamworks build is in steamworks/ (see Layers).
    4. The dev app id file, if writeDevAppIdFile is set.
    5. SteamClient.Init(appId, asyncCallbacks: false) succeeds. Before it, SteamAppId and SteamGameId are 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) to Log.Error. Every failure ends in a SteamStatus plus a Steam.StatusMessage, logged at info level. As a last resort, any exception becomes InitFailed.

  • Steam.RunCallbacks() must be pumped once per frame on the main thread. Lobby results and await continuations 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 before SteamClient.Shutdown (Facepunch throws on the user’s SteamID afterwards), then removes the events again: they are static, and a later TryInitialize must not subscribe them twice.

  • App id outside Steam. SteamClient.Init(appId) sets SteamAppId in .NET’s environment. On macOS and Linux that never reaches the native getenv steam_api reads, and init fails with “No appID found” (Speed Golf, 2026-09-25), so the shim also calls libc setenv. Steam therefore starts the same way on all three OSes, also outside the Steam client and without a steam_appid.txt. Use restartThroughSteam (below) when a store build must run through Steam.

  • Dev app id. writeDevAppIdFile: true writes steam_appid.txt next to the executable, only when it is missing or different, and sets SteamAppId for the process. Never enable it in shipped builds: it bypasses Steam’s “launched through Steam” check. build/package-game.sh refuses a publish folder that contains the file.

  • Steam.RestartAppIfNecessary(uint appId) wraps SteamAPI_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.

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:

LayerFilesRule
Public facadeSteam, SteamLobby, SteamLobbyInfo, SteamAchievements, SteamOverlay, SteamRichPresence, SteamFriendNo Steamworks type in any signature, field or method body. Every member checks Steam.Valid first.
AdapterNative/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.
ShimMainframeEngine.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.
FacepunchPlugins/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.

TypeSurface
SteamTryInitialize, RunCallbacks, Shutdown, Status, StatusMessage, Valid, AppId, IsPlatformSupported, NativeLibraryFileName, SteamId, Username, Branch, GetLaunchCommandLine(flag), GetFriends(), GetFriendUsername, HasDLC
SteamStatusNotInitialized, Running, UnsupportedPlatform, InvalidAppId, NativeLibraryMissing, SteamNotRunning, InitFailed, Shutdown
SteamLobbyCreateLobbyAsync, JoinLobbyAsync, LeaveLobby, GetLobbyListAsync(friendsOnly), Current, Timeout (15 s), events OnLobbyUpdated, OnLobbyJoined, OnMemberChanged(lobby, user, joined)
SteamLobbyInfoLive lobby metadata: HostId, LobbyName, AppVersion, Country, IsAdvertising, PlayerCount, MaxPlayers, OwnerId, GetPlayers()
SteamAchievementsIsUnlocked, Unlock and Clear (both call StoreStats), StoreStats
SteamRichPresenceSet*, Clear, GetFriendConnect, GetFriendGroupSize, OnJoinRequested(connect)
SteamOverlayIsEnabled, OpenWeb, OpenStore, InviteFriendToGame(connect), InviteFriendToRemotePlay
SteamFriendreadonly struct (SteamId, Username)

Steam.GetFriends() returns immediate friends (Facepunch’s SteamFriends.GetFriends()).

Without Steam:

  • async lobby calls return null or an empty list;
  • getters return 0, "" or empty;
  • setters and overlay calls do nothing;
  • argument validation still throws, because that is a caller bug.
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).

PlatformFacepunch 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.

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.

Games set Steam in project.mfproj (steam section; the editor’s Project Settings › Steamworks), and GameHost applies it:

SettingDefaultEffect
appId (App ID)0The full game’s app id. 0 leaves Steam off.
demoAppId (Demo App ID)0The 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)trueDevelopment 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)falseShipped 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.

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.

// Host
mp.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.

  • Steam Networking Sockets. SteamSocketsTransport is 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 then steam: addresses are skipped and ENet is used.
  • Avatars (Facepunch’s SteamFriends.GetLargeAvatarAsync → RGBA → a Vulkan texture through the M3 GPU resource path, then UiServer.RegisterTexture for the UI) are not implemented yet (#58). The old commented-out Unity SteamAvatar and SteamRemotePlay files were deleted.
  • Member updates. OnLobbyUpdated reports lobby-level metadata updates only; member data updates are ignored.
  • Lobby continuations run inside RunCallbacks on the main thread. A continuation after a timeout runs on a thread-pool thread.

Networking · Build & platforms · ADR 0043: lobby connect strings · ADR 0044: Steam features without natives · ADR 0189: Facepunch.Steamworks