Jacking leaderboards and global stats into steamworks.js: the intel Steam never hands a netrunner
Street notes from jacking leaderboards, aggregated global stats and screenshots into my steamworks.js fork, covering handles that flatline after one Steam session, throttled uploads that resolve like the gig went clean, and global totals that run about a day behind the street.
Version 0.7.0 of Cozy Steamworks, the steamworks.js fork I keep chromed up for Cozy Coast, jacked in two new namespaces, leaderboard and global_stats, and version 0.8.0 bolted screenshots onto the same rig. None of the Steamworks calls underneath are long, choom, and a leaderboard upload is a single function call, the kind of gig a rookie merc could pull. Where I actually burned my hours and my eddies was on the dark alley around those calls, because some requests flatline by never answering at all, and some results walk back through the door looking like a clean gig while carrying zero eddies of useful data.
This post runs through the ICE I hit one piece at a time, along with the code I wrote to crack each of them. Everything I mention is in the repo, mostly in src/api/ and in the scripts under test/, so any netrunner who wants to check my work can jack in and read the source on their own deck.
Leaderboard handles flatline after one Steam session
When you jack in with findLeaderboard('Feet Traveled'), you get back a Leaderboard object with an id on it, which is a bigint. It looks like exactly the kind of loot a merc would want to stash in a save file, so that the next time the game boots you can skip the lookup, skip the fixer, and go straight to the gig.
Don’t do that, choom, because the id is a raw SteamLeaderboard_t and it is only valid for the current Steam session, like a burner number that goes dead the moment the call ends. That is why the doc comment on the class tells you to stash the name and look the board up again on every run, and why the id field is marked as diagnostic only: it is a dog tag a ripperdoc can read off your corpse, not a key you can reuse on the next job.
If you ignore that and jack in with an old id, what you get back is a hang rather than an error. Steam’s ICE answers a request made against a stale handle with k_uAPICallInvalid, but the steamworks crate registers its callback under that invalid call handle anyway, so the callback never fires and nobody ever rings you back. In my first build the promise simply stayed pending forever, like a merc waiting in the Afterlife for a fixer who already got flatlined, and a client that is logged out of Steam produces exactly the same dead silence on the line. To make sure a caller always gets an answer, every Steam await in the leaderboard and global stats modules now runs under a 15 second tokio timeout, a hard deadline no fixer can stall past, and when the timer runs dry the promise rejects with a readable message such as Timed out waiting for Steam after 15s (score upload), which any netrunner can act on instead of staring at a frozen rig.
Leaderboard names also get scanned by the ICE at the door before they ever reach Steam, because a few bad inputs do far more damage than a clean error ever would. An empty name gets bounced, and so does a name longer than 128 bytes (k_cchLeaderboardNameMax), since it would trigger the same k_uAPICallInvalid hang described above and leave your run hanging in the dark. The most dangerous gonk input is a NUL byte in the name: the crate builds a CString from the name and panics, and a panic inside the Steam callback machinery flatlines the whole process along with it, taking your game down like a cyberpsycho with no off switch. The binding’s ICE now rejects that case in Rust, which means you get an error you can catch instead of a zeroed rig and a crash report full of static.
findOrCreateLeaderboard only takes its chrome once
findOrCreateLeaderboard(name, sortMethod, displayType) is preem when you do not want to wire a board up by hand, but its arguments do not behave the way a street-smart choom might expect. The sort method and the display type are only applied at the moment the board is created, like chrome that gets installed once on the ripperdoc’s table. After that, the board keeps whatever chrome it came out of the clinic with, and later calls that pass different values do nothing to it at all, no matter how many eddies of good intentions you pour in.
On the street this means that if the very first build that ever ran shipped Descending for a speedrun board, fixing the code afterwards does not fix the board, and your fastest samurai stay ranked like they finished last. You have to change it on the Steamworks site, because that is the only ripperdoc licensed to operate on chrome that is already installed. There is a second piece of street intel about boards created from code, which is that they stay off the Steam Community street until someone sets a Community Name for them in App Admin, like a merc with no handle that no fixer will book. I got that detail wrong in the first version of the doc comment and corrected it in a follow-up commit, so that gonk move is on my record, not yours.
Uploading scores: KeepBest and the rate limit ICE
await board.uploadScore(lb.UploadScoreMethod.KeepBest, 4200, [level, seed])
The first argument decides what goes down when the player already has a score on the board. KeepBest keeps the stored score if the new one does not beat it, while ForceUpdate always replaces it, no questions asked, like a corpo overwriting the records. The object the promise resolves to tells you which of the two happened: wasChanged is false when KeepBest discarded your upload, and you also get globalRankNew and globalRankPrevious, so you can show the player how far their street cred climbed or slid on this run.
The promise can also come home zeroed, resolving to null, which means Steam reported the upload as unsuccessful. The most common reason is the rate limit, which is roughly 10 uploads per 10 minutes per user, Steam’s own ICE wrapped around the score endpoint. The part that bites is that a throttled upload is not treated as an error. The promise resolves normally with null, so if your code does not check for it, you will walk away thinking the score got banked when it never left the street. The practical rule that follows is to upload when a run ends and never on every tick of a counter, because each upload spends eddies from a very small wallet and the ICE does not refund them. The test script treats null as a skip for this reason, and it only uploads anything when you set LEADERBOARD_UPLOAD=1, because each run of the script burns one of those ten uploads, and a netrunner testing all afternoon would zero the budget fast.
The third argument is the details payload, the merc’s cargo, an array of at most 64 ints that Steam stores riding shotgun alongside the score. If you pass 65, the binding’s ICE bounces the call before Steam ever sees it, so the gonk payload never leaves your deck. Reading the details back hides its own ICE, because downloadEntries takes a maxDetails argument that defaults to 0, and with 0 every entry comes back with details: []. The data is still sitting in Steam’s vault, so when you see empty arrays it usually means you never asked the fixer for the goods, and passing a maxDetails value is enough to get them back.
Where each request type points its scanner
// Absolute 1 based ranks.
const top10 = await board.downloadEntries(lb.LeaderboardDataRequest.Global, 1, 10, 2)
// Offsets relative to your own rank.
const around = await board.downloadEntries(lb.LeaderboardDataRequest.GlobalAroundUser, -4, 5, 2)
const friends = await board.downloadEntries(lb.LeaderboardDataRequest.Friends, 0, 0)
downloadEntries always takes the same two numbers, start and end, but what they mean depends on the request type you jack in with. For Global, they are absolute ranks counting from 1, so 1, 10 hands you the top ten samurai on the board, the legends the whole street talks about. For GlobalAroundUser, they are offsets from the local player’s own rank, which is why they are allowed to go negative: -4, 5 returns your own row plus the four entries above it and the five below it, like a scan of your own floor in the megabuilding. For Friends, Steam ignores the range completely and returns every friend who has an entry on the board, your whole crew and not a single stranger. Whatever the request type, a single request is capped at 5000 entries, the hard ceiling on any one run.
Supporting the negative offsets took a little back-alley ripperdoc work on the Rust side. The steamworks crate at 0.13.1 takes the range as usize, which cannot hold -4, and then casts it down to the SDK’s int before calling Steam. So the binding accepts i32 from JavaScript and does start as usize before the call. The sign extends on the way in and survives the cast back down, like a smuggled chip that clears the scanner in both directions, so Steam still receives the negative value it expects and the ICE never notices. It is the only way to express that range through this version of the crate, street-legal or not, and I left a comment in leaderboard.rs explaining it, because any netrunner reading that line cold would reasonably swear it is a bug and try to patch it out. A start greater than end, on the other hand, gets zeroed at the door, since a call like downloadEntries(Global, 10, 1) is almost certainly a gonk mistake rather than a plan.
Global stats run about a day behind the street
The global_stats namespace reads totals aggregated across every player on the net, and most of the ICE is in the setup on Steam’s side, which is exactly where chooms tend to trip. A stat can only be read through this API if it is marked as aggregated in the Steamworks App Admin, and Steam only starts counting from the moment you flip that switch, so nothing from before that moment ever lands in the pot. On top of that, the totals trail live play by roughly a day, like street news that reaches the Afterlife a shift late. That rules out something like a “fish caught worldwide” counter that ticks up while the player watches, because this API cannot sell you intel that fresh, no matter how many eddies you wave at the fixer.
// Totals, plus seven days of day-by-day history. Resolve before reading.
await client.global_stats.requestGlobalStats(7)
const total = client.global_stats.getGlobalStatInt64('NumGames') // bigint or null
const rate = client.global_stats.getGlobalStatDouble('Distance') // number or null
const daily = client.global_stats.getGlobalStatHistoryInt64('NumGames', 7) // index 0 is today
Until requestGlobalStats resolves, the getters return null and [], and they keep returning those values when a stat is not aggregated for the app, so an empty readout alone tells a netrunner nothing. What caught this choom off guard is that the request also resolves when Steam answered it with a failure. Steam does report a per-request EResult, but the crate does not pass it through, so the binding has no way of telling a clean reply from a botched gig. All a resolved promise tells you is that Steam picked up the line, not that any data came down it, which is why you need to check for null every time you read a stat, the same way a merc counts the eddies before leaving the fixer’s booth.
The history array comes back freshest first, so index 0 is today, and it is only as long as the number of days Steam actually coughed up, never padded out with fake intel. historyDays is clamped to 60 because that is the SDK maximum, the hard ceiling on how far back the corpo archive lets you scroll, and the days value you pass to the history getter should not be larger than what you requested.
Per-user stats run a different racket from all of this since SDK 1.64. The Steam client now syncs them before the game process even boots, and the SDK flatlined RequestCurrentStats altogether, which is why init in the fork no longer calls it and no choom has to wait on that handshake anymore.
64-bit values come back as bigint chrome
getGlobalStatInt64 returns a bigint, and so does every value in the history array, a leaderboard’s id, and the steamId64 on each downloaded entry, so that chrome is everywhere in the namespace. Two things flatline the first time you forget about this. JSON.stringify throws a TypeError when it meets a bigint, and an expression like total + 1 throws because JavaScript does not let you mix bigint and number in arithmetic, which is like slotting the wrong calibre into a Night City pistol and watching it jam mid-gig. Because of the first problem, the leaderboard and global stats test scripts both carry the same one-line replacer, which they need in order to print their results at all:
const json = (v) => JSON.stringify(v, (_, x) => typeof x === 'bigint' ? String(x) : x)
Float and average-rate stats dodge this whole gonk mess, because they go through getGlobalStatDouble and come back as plain numbers, no extra chrome required and nothing for a gonk to trip over.
Dropping screenshots into the library without the overlay
The screenshots namespace jacked in later, but it rides in this post because it carries the same kind of ICE: it is an async write, and you only learn how the gig went from a callback, like a fixer who only calls once the job is done. addToLibrary(path, width, height) drops an image that the game rendered itself into the player’s Steam screenshot library, without involving the overlay. It accepts JPG, PNG or TGA files, and Steam cooks up the thumbnail for you, no extra eddies or netrunning required.
The path you pass has to be absolute, choom. The crate canonicalizes the path against the working directory, so it would happily accept a relative path and resolve it against wherever the process happened to be launched from, like a scav grabbing whatever is lying in the nearest alley and calling it the package. The binding returns null in that case instead, and it does the same for a path containing a NUL byte and for any file that Steam refuses at the door. When you do get a number back, it means Steam accepted the file, but it does not mean the file has been written yet, so the gig is taken, not finished, and the merc still owes you delivery.
const me = client.localplayer.getSteamId().steamId64
const sub = client.callback.register(steamworks.SteamCallback.ScreenshotReady, ({ handle, result }) => {
if (handle !== shot || result !== 1) return
client.screenshots.setLocation(shot, 'Deep Sea')
client.screenshots.tagUser(shot, me)
})
const shot = client.screenshots.addToLibrary(file, 320, 180)
That is why the example gig waits for ScreenshotReady before calling setLocation or tagUser. The callback jacks in carrying the raw EResult number, and a value of 1 (k_EResultOK) means the screenshot has been written and can be tagged, the fixer’s all-clear. The crate does ship its own ScreenshotReady type, but it does not derive Serialize, and it squashes the EResult down to either Ok or Fail, which is cheap corpo chrome that throws away the actual code. So the fork pulls the two fields straight out of memory with offset_of!, netrunner style, instead of going through the bindgen EResult enum. I avoided the enum on purpose, because if Steam sent a value that the enum does not list, reading it as that enum would be undefined behaviour inside SteamAPI_RunCallbacks, and that is the kind of glitch that fries the whole rig rather than one call.
Jacking the test scripts into Spacewar
Each namespace has its own script, node test/leaderboard.js, node test/global_stats.js and node test/screenshots.js, and all three jack into Spacewar, app 480, with a Steam client that is logged in. The two that hit a real account are opt in, gigs you have to sign up for, so that running them never spends a single eddie by accident: LEADERBOARD_UPLOAD=1 enables a real score upload, and SCREENSHOT_UPLOAD=1 drops a generated gradient PNG into your actual screenshot library. The global stats test also races the request against a 20 second timer of its own, a second layer of ICE on top of the first. It needs that timer because index.js keeps a 30 Hz callback pump alive on an interval, so a promise that never settled would keep the process running forever like a body left on life support, instead of letting the test flatline cleanly and report the failure. It is the same problem as the stale leaderboard handle at the start of this post, a request that never answers, showing up one layer higher in the test harness like the same ghost haunting a different floor of the megabuilding.