steamworks-ffi-node: A Steamworks SDK Library for JavaScript Game Frameworks

Published: (March 20, 2026 at 05:25 PM EDT)
9 min read
Source: Dev.to

Source: Dev.to

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

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 — 100% coverage (all 20 functions) Stats — 100% coverage (all 14 functions) Leaderboards — 100% coverage (all 7 functions) Friends — 22 complete social functions Rich Presence — 6 functions for custom status display Cloud Storage — Full file management support Steam Input — Controller/gamepad support Steam Overlay — Native overlay for Electron apps 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 = Steamwor

ksSDK.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.RankedByTe

xtSearch, 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  {
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();

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

Bulletail DekaDuck Falling Face Fragments Idle Looter Maldhalla Realms of Conflict 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

0 views
Back to Blog

Related posts

Read more »