Forking steamworks.js: fresh chrome for Cozy Coast's multiplayer hub
Upstream steamworks.js 0.4.0 only offered legacy P2P, a lobby list with no filter and no friends API, which was not enough chrome to run Cozy Coast's multiplayer. This covers what the fork bolted on, what it flatlined, and the eddies it costs to keep a fork alive for a single game.
Cozy Coast is an Electron app, choom, and Electron has no way of jacking into Steam on its own wetware. To get there you need a native Node module that wraps the C++ Steamworks SDK, a bit of ripperdoc chrome grafted between the game and the megacorp’s client, and for most netrunners that module is steamworks.js by ceifa, a Rust binding built on the steamworks crate and napi. That is where I jacked in as well, same as every other merc on the street.
For a single-player gig it covers what you would expect: achievements, cloud, the overlay and stats. Where that chrome stopped being enough was the hub, the multiplayer village I broke down in Cozy Coast multiplayer runs on zero servers. That post already runs through the architecture, so I will not replay the braindance 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 rewired in that rig and why.
What 0.4.0 was missing from its chrome
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 answer to SteamClient021. On its own that older chrome runs fine, no glitch, but it means anything Valve has added to the SDK since then sits behind corpo ICE until you update both the crate and the binaries.
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, the megacorp holding the keys here, has deprecated that interface, and its replacement, ISteamNetworkingMessages, was not wrapped at all, so building the hub on what upstream offered would have meant running a brand new gig on netrunning gear the corpo has already scheduled for the scrapyard.
The lobby side had several smaller holes that stacked up into a real problem. getLobbies() takes no arguments, so you get whatever list Steam dumps on you and have no way of asking for something like “same protocol version, at least one open slot”, which is a bit like a fixer sliding every job in Night City across the table with no way to say which ones fit your crew. The lobby owner can be read but not changed, so whoever opened the room runs it until they flatline or delta out. There is also no friends namespace, which means no friends list and no way to invite a choom 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 was dead wire on that whole platform.
Leaderboards, global stats and screenshots were missing from the rig too. None of them block multiplayer, but I wanted that chrome in the game, and once you are already running your own fork, bolting on one more namespace costs very few eddies.
Bumping the crate and flatlining 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 fried the Workshop (UGC) wrappers like a cheap deck hit by black ICE. 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 made about as much sense as paying a ripperdoc to service a cyberarm I had already had removed. I deleted workshop.rs and workshop_item.rs, 426 and 580 lines respectively, in a single commit, and zeroed that whole corner of the codebase.
That commit is where the fork stopped being a general-purpose library and became a one-client merc. Other upstream users do rely on Workshop, and for them that cut would be a flatline, 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 pulled the RequestCurrentStats call out of init. SDK 1.64 no longer has it, because the Steam client now syncs stats and achievements before the game process even boots, so there was nothing to slot in its place and nothing left for my code to run. I also had to swap 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, and mismatched chrome like that looks preem on the table right up until the body rejects it. I pulled the new binaries from the source of the steamworks-sys 0.13.0 crate.
Wrapping ISteamNetworkingMessages for the netrunners
The new networking_messages namespace wraps ISteamNetworkingMessages. That interface is connectionless, no standing line to keep alive like old-school netrunning, which means you fire a message at a Steam ID and Steam opens a session on demand, routing the traffic through its own relay rig, a fixer passing notes between crews, when there is no direct line between the two players’ decks.
The tricky part of the run 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 like a merc calling their fixer mid-gig, because the callback will not sit around while you ping another process for a verdict, so the fork makes JavaScript declare its door policy up front, and Rust plays bouncer with 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 puts chooms on the guest list as they enter its lobby and scratches them off as they delta out. That setup has a race wired into it: if a peer’s first message arrives before the host has called allowPeer, the bouncer turns the session away once. The sender catches the rejection through the failure handler, calls closeSessionWithUser, and its next send opens a fresh request, which gets waved through this time. I documented that behaviour in the fork rather than pretending the race cannot happen, because a gonk who swears his code has no races is usually the one who gets flatlined in production. There is also setAllowAllSessions(true), but it exists for private playtests with a trusted crew, and leaving the Afterlife doors that wide open on a shipped build is an invitation to every scav in the city.
A review pass led to two changes I would have regretted skipping. sendMessageToUser originally returned a bare bool, which tells the caller about as much as a fixer shrugging at you across the bar. It now throws with the EResult name, so a caller can tell NoConnection, where the right move is to close the session and retry the run, apart from LimitExceeded. The second change is that getSessionConnectionInfo now reads the Relayed connection flag, so that usingRelay comes back true for TURN as well as for Steam Datagram Relay, and the netrunner reading it gets the real route instead of half the story.
Receiving works by polling, because messages never ride in through callbacks. The game calls receiveMessagesOnChannel(channel, batchSize) on an interval, like a merc checking a fixer’s dead drop on a fixed run, and processes whatever has come down the wire since the last call. Nothing gets pushed at the game, so the netrunner decides when to look.
Lobbies, chooms 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, like a deck running whatever chips are still slotted from the last gig. If a call died halfway through, it would leave a half-built filter set lying in the alley for the next request to pick up and wear like stolen chrome, so the wrapper validates every key and value (length, NUL bytes) before it pushes anything at all.
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, so this is bare-wire netrunning, kept short and contained. The invite is delivered as a Steam chat message. When your choom 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 the clean run I needed on macOS.
I kept the friends namespace lean on purpose, street chrome rather than a corpo feature suite, so it only carries four functions: getFriends, getFriendName, requestUserInformation and inviteUserToGame.
Keeping callbacks from flatlining the game
This is the part of the fork I am proudest of, even though nobody playing the game should ever notice this ICE doing its job.
Steam callbacks run inside SteamAPI_RunCallbacks, and a Rust panic in there aborts the whole process, a hard flatline with no respawn. 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, any scav with a malformed invite could zero your game from across the city. To shut that run down, the fork defines its own mirror of the callback and decodes the string lossily instead, so garbage coming in becomes a garbage string while the game keeps breathing.
ScreenshotReady got the same treatment for a different reason. The crate folds the EResult into Ok or Fail, which throws away the real intel the corpo servers sent back, and reading a value that is not listed in the bindgen enum would be undefined behaviour, the kind of quiet cyberpsychosis that never shows on a scan. So the fork reads the raw integer from memory at the field’s offset and forwards it unchanged, the way a street netrunner reads the wire directly instead of trusting a corpo summary. Both new callbacks are appended to the end of SteamCallback, so that the existing numeric values stay the same and nothing shifts under the feet of any choom who already depends on them.
There are also smaller patches 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, like a counterfeit chip that sails through inspection. 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, a merc waiting in a booth for a fixer who is never showing up. Finally, runCallbacks is exported, so that if the built-in 30 Hz pump ever proves unreliable I can jack in and drive the callbacks myself.
The leaderboard, global stats and screenshots namespaces were an easy gig by comparison. screenshots.addToLibrary takes an image the game has rendered to disk, and like the invites, it runs without ever touching the overlay.
What a single-game fork costs in eddies
To measure how far the fork has drifted from the street it grew up on, 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 flatlined Workshop files. The generated client.d.ts went from 13 namespaces to 17, with five bolted on and one ripped out.
The fork keeps ceifa’s repository wired in as an upstream remote, but every upstream change now lands in a body that has no Workshop module and a newer crate. In practice that means pulling in a change is a port rather than a merge, because chrome you tear out is chrome you have to route around from then on, and that is the tab I run up for deleting code I did not need. The README says it straight: bug reports and general API questions belong upstream with the original fixer, and the fork only documents where it differs.
Delta out of npm: shipping through GitHub Releases
I also changed how the package gets published, and the fork stays off the npm grid entirely. Pushing a v* tag that matches the version in package.json triggers a release job. That job pulls 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 hangs the tarball on a GitHub Release. Consumers jack in by installing the package from the tarball URL, which means I do not need an npm account or a token that has to be rotated before some corpo breach leaks it onto the net. A plain git dependency would not work here, because the prebuilt binaries in dist/ only exist inside the tarball, and a git pull would hand you the deck without the chips.
All of this is one more piece of chrome to maintain for a game built by one choom. I still think it is a preem trade, because Cozy Coast ships on macOS, the overlay never shows up in an Electron window there, and Lobby.inviteUser is what lets an invite actually reach your choom instead of relying on a dialog that never opens.