Forking steamworks.js for Cozy Coast's multiplayer hub
Upstream steamworks.js 0.4.0 only offered legacy P2P, an unfiltered lobby list and no friends API, which was not enough for Cozy Coast's multiplayer. This covers what the fork added, what it removed, and what it costs to maintain a fork for a single game.
Cozy Coast is an Electron app, and Electron has no way of talking to Steam by itself. To get there you need a native Node module that wraps the C++ Steamworks SDK, and for most people that module is steamworks.js by ceifa, a Rust binding built on the steamworks crate and napi. That is where I started as well.
For a single-player game it covers what you would expect: achievements, cloud, the overlay and stats. Where it stopped being enough was the hub, the multiplayer village I described in Cozy Coast multiplayer runs on zero servers. That post goes through the architecture, so I will not repeat it here. What I want to cover in this one is the binding underneath it, because I ended up maintaining a fork, packaged as @cozycoast/steamworks.js, and I want to explain what I changed in it and why.
What 0.4.0 was missing
The upstream release I forked from was 0.4.0. It pins the steamworks crate to a git revision of the 0.11.0 line, and it ships the redistributables from a 1.5x SDK, the ones that report SteamClient021. On its own that is not a problem, but it means anything Valve has added to the SDK since then is out of reach until you update both.
The biggest gap for me was networking, where upstream exposes a networking namespace that wraps the legacy ISteamNetworking interface through sendP2PPacket, isP2PPacketAvailable, readP2PPacket and acceptP2PSession. Valve has deprecated that interface, and its replacement, ISteamNetworkingMessages, was not wrapped at all, so building the hub on what upstream offered would have meant building a new feature on an API that is on its way out.
The lobby side had several smaller gaps that added up. getLobbies() takes no arguments, so you get whatever list Steam returns and have no way of asking for something like “same protocol version, at least one open slot”. The lobby owner can be read but not changed. There is also no friends namespace, which means no friends list and no way to invite someone other than Lobby.openInviteDialog(), and that only works through the Steam overlay. The problem is that the overlay does not inject into Electron on macOS, so for a game that ships on Mac, the only invite path upstream offered simply could not work there.
Leaderboards, global stats and screenshots were missing too. None of them block multiplayer, but I wanted them in the game, and once you are maintaining a fork anyway, adding one more namespace costs very little.
Updating the crate and removing Workshop support
The first commit bumped the crate to steamworks 0.13.1, which compiles against SDK 1.64. Between 0.11 and 0.13 the crate dropped its Manager generic parameter, and that broke the Workshop (UGC) wrappers. Cozy Coast has no Workshop content and I have no plans to add any, so porting two files for a feature I would never call did not make sense. I deleted workshop.rs and workshop_item.rs, 426 and 580 lines respectively, in a single commit.
That commit is where the fork stopped being a general-purpose library. Other upstream users do rely on Workshop, but since this fork only has to serve one game, I could make that call based purely on what Cozy Coast needs.
The same bump also removed the RequestCurrentStats call from init. SDK 1.64 no longer has it, because the Steam client now syncs stats and achievements before the game process starts, so there was nothing to replace it with. I also had to refresh the vendored redistributables by hand. The binaries that build.js copies into dist/ were still the old SteamClient021 ones, while the crate was now compiling against the 1.64 headers (SteamClient023), so the two no longer matched. I took the new binaries from the source of the steamworks-sys 0.13.0 crate.
Wrapping ISteamNetworkingMessages
The new networking_messages namespace wraps ISteamNetworkingMessages. That interface is connectionless, which means you send a message to a Steam ID and Steam opens a session on demand, relaying the traffic when there is no direct route between the two players.
The tricky part is accepting sessions. When the first message from a new peer arrives, Steam fires a callback and expects you to accept or reject the session right there, synchronously, inside the Rust callback. There is no way to hand that decision over to JavaScript and wait for an answer, so the fork asks JavaScript to declare its policy up front, and Rust applies that policy when the callback fires:
nm.initSessionCallbacks(
(steamId64, accepted) => { /* notified after the decision */ },
(steamId64) => nm.closeSessionWithUser(steamId64),
)
client.callback.register(steamworks.SteamCallback.LobbyChatUpdate, ({ user_changed, member_state_change }) => {
if (member_state_change === 'Entered') nm.allowPeer(user_changed)
else nm.disallowPeer(user_changed)
})
In practice the host allows peers as they enter its lobby and revokes them as they leave. That setup has a race built into it: if a peer’s first message arrives before the host has called allowPeer, the session gets rejected once. The sender sees the rejection through the failure handler, calls closeSessionWithUser, and its next send opens a fresh request, which is accepted this time. I documented that behaviour in the fork rather than pretending the race cannot happen. There is also setAllowAllSessions(true), but it exists for private playtests and should not be used for anything else.
A review pass led to two changes I would have regretted skipping. sendMessageToUser originally returned a bare bool, which tells the caller nothing about what went wrong. It now throws with the EResult name, so a caller can tell NoConnection, where the right response is to close the session and retry, apart from LimitExceeded. The second change is that getSessionConnectionInfo now reads the Relayed connection flag, so that usingRelay is true for TURN as well as for Steam Datagram Relay.
Receiving works by polling, because messages do not arrive through callbacks. The game calls receiveMessagesOnChannel(channel, batchSize) on an interval and processes whatever has come in since the last call.
Lobbies, friends and invites
getLobbies(filter?) now takes an optional object and maps it onto Steam’s string, number, near-value, open-slot, distance and result-count filters. The catch is that Steam’s filter API is stateful: you push filters one by one, and the next lobby request consumes whatever has been pushed. If a call failed halfway through, it would leave a partial filter set behind for the next request to pick up, so the wrapper validates every key and value (length, NUL bytes) before it pushes anything.
Lobby.setOwner() and Lobby.inviteUser(steamId64) call SetLobbyOwner and InviteUserToLobby through the crate’s raw bindings, because the crate’s safe wrapper does not expose either function. The invite is delivered as a Steam chat message. When the friend accepts it, Steam launches the game with +connect_lobby <id>, or fires GameLobbyJoinRequested if the game is already running. The overlay is not involved anywhere in that flow, which is exactly what I needed on macOS.
I kept the friends namespace small on purpose, so it only has four functions: getFriends, getFriendName, requestUserInformation and inviteUserToGame.
Keeping callbacks from crashing the game
This is the part of the fork I am happiest with, even though nobody playing the game should ever notice it.
Steam callbacks run inside SteamAPI_RunCallbacks, and a Rust panic in there aborts the whole process. The crate’s own GameRichPresenceJoinRequested callback decodes the connect string strictly, and it panics if the payload is not NUL-terminated or not valid UTF-8. That payload is chosen by whoever sent the invite, which means that, in principle, a stranger could crash your game by sending a malformed invite. To avoid that, the fork defines its own mirror of the callback and decodes the string lossily instead.
ScreenshotReady got the same treatment for a different reason. The crate folds the EResult into Ok or Fail, and reading a value that is not listed in the bindgen enum would be undefined behaviour. So the fork reads the raw integer from memory at the field’s offset and forwards it unchanged. Both new callbacks are appended to the end of SteamCallback, so that the existing numeric values stay the same for anyone who already depends on them.
There are also smaller fixes along the same lines. setRichPresence now returns Steam’s accept or reject result instead of discarding it, because before that change a rejected steam_display token looked exactly like a success. I added clearRichPresence, and member_state_change is now typed as the variant name string that it actually is. Every oneshot await in matchmaking, leaderboards and global stats runs under a 15 second timeout, because without one a stale handle after a reconnect would leave a promise pending forever. Finally, runCallbacks is exported, so that if the built-in 30 Hz pump ever proves unreliable I can drive the callbacks myself.
The leaderboard, global stats and screenshots namespaces were simple by comparison. screenshots.addToLibrary takes an image the game has rendered to disk, and like the invites, it does not need the overlay.
What maintaining a single-game fork costs
To measure how far the fork has drifted, I ran git diff --shortstat from upstream’s last merge commit in my history (80c5fd7) to the fork’s head. That covers 16 commits, 42 files changed, 2,979 lines added and 1,378 removed. Under src/ alone it is 1,427 lines added and 1,028 removed, and 1,006 of those removals are the Workshop files. The generated client.d.ts went from 13 namespaces to 17, with five added and one deleted.
The fork keeps ceifa’s repository as an upstream remote, but every upstream change now lands in a codebase that has no Workshop module and a newer crate. In practice that means pulling in a change is a port rather than a merge, and that is the price of deleting code I did not need. The README says this directly: bug reports and general API questions belong upstream, and the fork only documents where it differs.
Publishing through GitHub Releases instead of npm
I also changed how the package gets published, and the fork does not go to npm at all. Pushing a v* tag that matches the version in package.json triggers a release job. That job downloads the builds for the four CI targets (Windows x64, Linux x64, macOS x64 and arm64), checks that every .node binary is present and that the tag matches the package version, then runs npm pack and attaches the tarball to a GitHub Release. Consumers install the package from the tarball URL, which means I do not need an npm account or a token that has to be rotated. A plain git dependency would not work here, because the prebuilt binaries in dist/ only exist inside the tarball.
All of this is one more thing to maintain for a game made by one person. I still think it is worth it, because Cozy Coast ships on macOS, the overlay never appears in an Electron window there, and Lobby.inviteUser is what lets an invite actually reach a friend instead of relying on a dialog that never opens.