steamworks-ffi-node is an open source and zero-compilation Steamworks SDK wrapper for Node.js, Electron, and JavaScript game frameworks. It covers achievements, leaderboards, cloud saves, Steam Workshop, Steam Input, and cross-platform Steam Overlay — all in TypeScript with no native build tools required.
How It Started
I was building an Electron app for release on Steam. Everything went smoothly until I needed to add Steam integrations: achievements, leaderboards, cloud saves, and Steam Overlay support.
The first thing I found were two existing libraries: greenworks and steamworks.js. I tried both, but both had significant drawbacks.
greenworks disappointed me because of missing Steam integrations, the need to compile the correct library version, and its focus on NW.js.
steamworks.js had poor maintenance, lacked documentation, and had Steam Overlay issues — it only worked on Windows.
With the rise of AI coding agents, I decided to build my own library. That's how steamworks-ffi-node was born — a Node.js wrapper for Steamworks SDK that uses FFI instead of native C++ compilation.
The Problem: Steam SDK and JavaScript Don't Mix Well
Valve provides Steamworks SDK as a set of C++ headers and dynamic libraries (steam_api64.dll, libsteam_api.so, libsteam_api.dylib). To call these functions from
Node.js, there are two classic approaches:
- Native Node.js addon in C++ (Node-API / NAN) — compiled separately for each platform and Node version.
- FFI (Foreign Function Interface) — call library functions directly from JavaScript, with no compilation step.
greenworks and steamworks.js chose the first approach. steamworks-ffi-node chose the second.
Overview of Existing Libraries
greenworks
greenworks is the oldest library in this space, originally developed by Greenheart Games for their game Game Dev Tycoon, later open-sourced.
Approach: Native C++ addon built with NAN (Native Abstractions for Node.js).
Issues I encountered:
- Maintained on a best-effort basis
- No TypeScript typings
- Supports Steamworks SDK v1.62 but parts of the API remain uncovered
- High focus on NW.js
- Last release: v0.22.0 (September 2025), 61 open issues
steamworks.js
steamworks.js is a more modern alternative, built with Rust via NAPI-RS.
What's good: No manual compilation needed, TypeScript typings included, modern API design.
Limitations I hit:
- Tied to specific Node.js versions via NAPI ABI
- Significantly limited API coverage
- Maintainer activity currently absent
- Last release: v0.4.0 (two years ago on npm), 52 open issues
The Technical Decision: Why FFI Over Native Addons
How Koffi Works
Koffi is an FFI library for Node.js that lets you call functions from native dynamic libraries directly from JavaScript — no C++ code required.
import koffi from 'koffi';
// Load the Steam API library
const lib = koffi.load('steam_api64');
// Declare a function signature
const SteamAPI_Init = lib.func('bool SteamAPI_Init()');
// Call it directly from JavaScript!
const result = SteamAPI_Init();
No Compilation on Install
The FFI approach means the library is pure JavaScript/TypeScript that installs as a regular npm package. No node-gyp, no Visual Studio Build Tools, no Xcode.
Node.js Version Independence
Native addons are tied to the ABI of a specific V8/Node version. Upgrading Electron forces recompilation or waiting for a new release. Koffi FFI calls .so/.dll directly. The Steam binary ABI doesn't change. Upgrading Electron doesn't break the integration.
Easy to Contribute
The entire library is written in TypeScript, so there's no separate compilation step. Adding a new Steamworks function is straightforward.
Tricky Technical Challenges
The Struct Return ABI Problem on Linux
This was the most interesting bug I fixed in version 0.9.3. The Steam Input API returns structures directly from functions.
On Windows (MSVC x64 ABI), small structs are returned via registers RAX/RDX. On Linux (System V x86_64 ABI), small structs up to 16 bytes return in RAX:RDX registers. Large structs (like InputMotionData_t at 40 bytes) return via a hidden pointer passed as the first argument.
My incorrect code passed the buffer as the second argument — the function wrote 40 bytes into ISteamInput , causing memory corruption and segfaults.
// INCORRECT — worked only on Windows
const buf = Buffer.alloc(8);
this.steamLib.func('SteamAPI_ISteamInput_GetDigitalActionData', 'void',
['void*', 'void*', 'uint64', 'uint64'])(iface, buf, handle, actionHandle);
The fix: declare structs through Koffi and use the correct return type. Koffi automatically selects the correct calling convention depending on platform and struct size.
// CORRECT: let Koffi handle the ABI
const InputMotionData_t = koffi.struct('InputMotionData_t', {
rotQuatX: 'float',
rotQuatY: 'float',
rotQuatZ: 'float',
rotQuatW: 'float',
posAccelX: 'float',
posAccelY: 'float',
posAccelZ: 'float',
rotVelX: 'float',
rotVelY: 'float',
rotVelZ: 'float',
});
const GetMotionData = lib.func(
'InputMotionData_t SteamAPI_ISteamInput_GetMotionData(void*, uint64)'
);
Native Steam Overlay for Electron
The standard Steam Overlay (Shift+Tab) doesn't work in Electron games out of the box — Steam doesn't know how to inject its rendering into Chromium.
I implemented native C++ modules for each platform:
- macOS: Metal rendering via MTKView and CAMetalLayer
- Windows: OpenGL via Win32 window hooks
- Linux/SteamOS: OpenGL 3.3 with GLX (tested on Steam Deck Desktop Mode)
This is the only part requiring native compilation, but prebuilt binaries ship via npm.
Library Architecture
Each manager is responsible for one section of the Steamworks API, making it easy to add new APIs without modifying existing code.
Key API modules currently supported:
- Achievements - https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/ACHIEVEMENT_MANAGER.md)
- Stats — https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/STATS_MANAGER.md
- Leaderboards — https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/LEADERBOARD_MANAGER.md
- Friends — https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/FRIENDS_MANAGER.md
- Rich Presence — https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/RICH_PRESENCE_MANAGER.md
- Cloud Storage — https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/CLOUD_MANAGER.md
- Steam Input - https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/INPUT_MANAGER.md
- Steam Overlay - https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/STEAM_OVERLAY_INTEGRATION.md
Usage Example
import SteamworksSDK, {
LeaderboardSortMethod,
LeaderboardDisplayType,
LeaderboardUploadScoreMethod,
LeaderboardDataRequest,
EFriendFlags,
EUGCQuery,
EUGCMatchingUGCType,
EItemState,
} from "steamworks-ffi-node";
// Helper to auto-start callback polling
function startCallbackPolling(steam: SteamworksSDK, interval: number = 1000) {
return setInterval(() => {
steam.runCallbacks();
}, interval);
}
// Initialize Steam connection
const steam = SteamworksSDK.getInstance();
const initialized = steam.init({ appId: 480 }); // Your Steam App ID
if (initialized) {
// Start callback polling automatically (required for async operations)
const callbackInterval = startCallbackPolling(steam, 1000);
// Get current Steam language for localization
const language = steam.getCurrentGameLanguage();
console.log("Steam language:", language); // e.g., 'english', 'french', 'german'
// Get achievements from Steam servers
const achievements = await steam.achievements.getAllAchievements();
console.log("Steam achievements:", achievements);
// Unlock achievement (permanent in Steam!)
await steam.achievements.unlockAchievement("ACH_WIN_ONE_GAME");
// Check unlock status from Steam
const isUnlocked = await steam.achievements.isAchievementUnlocked(
"ACH_WIN_ONE_GAME"
);
console.log("Achievement unlocked:", isUnlocked);
// Track user statistics
const kills = (await steam.stats.getStatInt("total_kills")) || 0;
await steam.stats.setStatInt("total_kills", kills + 1);
// Get global statistics
await steam.stats.requestGlobalStats(7);
await new Promise((resolve) => setTimeout(resolve, 2000));
steam.runCallbacks();
const globalKills = await steam.stats.getGlobalStatInt("global.total_kills");
console.log("Total kills worldwide:", globalKills);
// Work with leaderboards
const leaderboard = await steam.leaderboards.findOrCreateLeaderboard(
"HighScores",
LeaderboardSortMethod.Descending,
LeaderboardDisplayType.Numeric
);
if (leaderboard) {
// Upload score
await steam.leaderboards.uploadLeaderboardScore(
leaderboard.handle,
1000,
LeaderboardUploadScoreMethod.KeepBest
);
// Download top 10 scores
const topScores = await steam.leaderboards.downloadLeaderboardEntries(
leaderboard.handle,
LeaderboardDataRequest.Global,
1,
10
);
console.log("Top 10 scores:", topScores);
}
// Access friends and social features
const personaName = steam.friends.getPersonaName();
const friendCount = steam.friends.getFriendCount(EFriendFlags.All);
console.log(`${personaName} has ${friendCount} friends`);
// Get all friends with details
const allFriends = steam.friends.getAllFriends(EFriendFlags.All);
allFriends.slice(0, 5).forEach((friend) => {
const name = steam.friends.getFriendPersonaName(friend.steamId);
const state = steam.friends.getFriendPersonaState(friend.steamId);
const level = steam.friends.getFriendSteamLevel(friend.steamId);
console.log(`${name}: Level ${level}, Status: ${state}`);
// Get avatar handles
const smallAvatar = steam.friends.getSmallFriendAvatar(friend.steamId);
const mediumAvatar = steam.friends.getMediumFriendAvatar(friend.steamId);
if (smallAvatar > 0) {
console.log(
` Avatar handles: small=${smallAvatar}, medium=${mediumAvatar}`
);
}
// Check if playing a game
const gameInfo = steam.friends.getFriendGamePlayed(friend.steamId);
if (gameInfo) {
console.log(` Playing: App ${gameInfo.gameId}`);
}
});
// Check friend groups (tags)
const groupCount = steam.friends.getFriendsGroupCount();
if (groupCount > 0) {
const groupId = steam.friends.getFriendsGroupIDByIndex(0);
const groupName = steam.friends.getFriendsGroupName(groupId);
const members = steam.friends.getFriendsGroupMembersList(groupId);
console.log(`Group "${groupName}" has ${members.length} members`);
}
// Check recently played with
const coplayCount = steam.friends.getCoplayFriendCount();
if (coplayCount > 0) {
const recentPlayer = steam.friends.getCoplayFriend(0);
const playerName = steam.friends.getFriendPersonaName(recentPlayer);
const coplayTime = steam.friends.getFriendCoplayTime(recentPlayer);
console.log(`Recently played with ${playerName}`);
}
// Set rich presence for custom status
steam.richPresence.setRichPresence("status", "In Main Menu");
steam.richPresence.setRichPresence("connect", "+connect server:27015");
// Open Steam overlay
steam.overlay.activateGameOverlay("Friends"); // Open friends list
steam.overlay.activateGameOverlayToWebPage("https://example.com/wiki"); // Open wiki
// Steam Cloud storage operations
const saveData = { level: 5, score: 1000, inventory: ["sword", "shield"] };
const buffer = Buffer.from(JSON.stringify(saveData));
// Write save file to Steam Cloud
const written = steam.cloud.fileWrite("savegame.json", buffer);
if (written) {
console.log("✅ Save uploaded to Steam Cloud");
}
// Check cloud quota
const quota = steam.cloud.getQuota();
console.log(
`Cloud storage: ${quota.usedBytes}/${
quota.totalBytes
} bytes (${quota.percentUsed.toFixed(2)}%)`
);
// Read save file from Steam Cloud
if (steam.cloud.fileExists("savegame.json")) {
const result = steam.cloud.fileRead("savegame.json");
if (result.success && result.data) {
const loadedSave = JSON.parse(result.data.toString());
console.log(
`Loaded save: Level ${loadedSave.level}, Score ${loadedSave.score}`
);
}
}
// List all cloud files
const cloudFiles = steam.cloud.getAllFiles();
console.log(`Steam Cloud contains ${cloudFiles.length} files:`);
cloudFiles.forEach((file) => {
const kb = (file.size / 1024).toFixed(2);
const status = file.persisted ? "☁️" : "⏳";
console.log(`${status} ${file.name} - ${kb} KB`);
});
// Steam Workshop operations
// Subscribe to a Workshop item
const subscribeResult = await steam.workshop.subscribeItem(123456789n);
if (subscribeResult.success) {
console.log("✅ Subscribed to Workshop item");
}
// Get all subscribed items
const subscribedItems = steam.workshop.getSubscribedItems();
console.log(`Subscribed to ${subscribedItems.length} Workshop items`);
// Query Workshop items with text search
const query = steam.workshop.createQueryAllUGCRequest(
EUGCQuery.RankedByTextSearch,
EUGCMatchingUGCType.Items,
480, // Creator App ID
480, // Consumer App ID
1 // Page 1
);
if (query) {
// Set search text to filter results
steam.workshop.setSearchText(query, "map");
const queryResult = await steam.workshop.sendQueryUGCRequest(query);
if (queryResult) {
console.log(
`Found ${queryResult.numResults} Workshop items matching "map"`
);
// Get details for each item
for (let i = 0; i < queryResult.numResults; i++) {
const details = steam.workshop.getQueryUGCResult(query, i);
if (details) {
console.log(`📦 ${details.title} by ${details.steamIDOwner}`);
console.log(
` Score: ${details.score}, Downloads: ${details.numUniqueSubscriptions}`
);
}
}
}
steam.workshop.releaseQueryUGCRequest(query);
}
// Check download progress for subscribed items
subscribedItems.forEach((itemId) => {
const state = steam.workshop.getItemState(itemId);
const stateFlags = [];
if (state & EItemState.Subscribed) stateFlags.push("Subscribed");
if (state & EItemState.NeedsUpdate) stateFlags.push("Needs Update");
if (state & EItemState.Installed) stateFlags.push("Installed");
if (state & EItemState.Downloading) stateFlags.push("Downloading");
console.log(`Item ${itemId}: ${stateFlags.join(", ")}`);
if (state & EItemState.Downloading) {
// If downloading
const progress = steam.workshop.getItemDownloadInfo(itemId);
if (progress) {
const percent = ((progress.downloaded / progress.total) * 100).toFixed(
1
);
console.log(
` Download: ${percent}% (${progress.downloaded}/${progress.total} bytes)`
);
}
}
if (state & EItemState.Installed) {
// If installed
const info = steam.workshop.getItemInstallInfo(itemId);
if (info.success) {
console.log(` Installed at: ${info.folder}`);
}
}
});
}
// Cleanup
clearInterval(callbackInterval);
steam.shutdown();
Comparison Table
| Feature | greenworks | steamworks.js | steamworks-ffi-node |
|---|---|---|---|
| Approach | C++ NAN addon | Rust NAPI-RS | Koffi FFI |
| Compilation needed | Yes | Precompiled | No |
| TypeScript | No | Yes | Yes |
| Node version lock | Yes | Yes (NAPI ABI) | No |
| API coverage | Partial | Limited | Extensive |
| Steam Overlay | support only NW.js | Windows only (partial Linux) for Electron | Win, macOS, Linux for Electron |
| Active maintenance | Best-effort | Inactive | Active |
Games Already Using steamworks-ffi-node
Conclusion
I gained invaluable experience working with the FFI approach, Steamworks SDK, and solving challenges like the Steam Input ABI bug and cross-platform Steam Overlay.
If you're building a game on Electron or another JS framework and need Steam integration, you now have a choice between three approaches:
- greenworks — for basic functionality if you don't mind C++ compiler dependencies
- steamworks.js — for limited API needs with a Rust implementation
- steamworks-ffi-node — for comprehensive API coverage, zero compilation, TypeScript support, and active development
Also if you will plan to use library or face any issues do not hesitate to drop a question or comment here or create a Github issue. Will be happy for any feedback from you!
If this was useful — drop a star on GitHub!
Links:
- Github: https://github.com/ArtyProf/steamworks-ffi-node
- NPM: https://www.npmjs.com/package/steamworks-ffi-node
- Docs: https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/README.md
- Steam Overlay integration details for Electron: https://github.com/ArtyProf/steamworks-ffi-node/blob/main/docs/STEAM_OVERLAY_INTEGRATION.md

Top comments (0)