DEV Community

Cover image for steamworks-ffi-node: A Steamworks SDK Library for JavaScript Game Frameworks
Artur Khutak
Artur Khutak

Posted on

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

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:

  1. Native Node.js addon in C++ (Node-API / NAN) — compiled separately for each platform and Node version.
  2. 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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)'
);
Enter fullscreen mode Exit fullscreen mode

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.

Steam Overlay integration for Electron applications design


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:


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();
Enter fullscreen mode Exit fullscreen mode

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:

Top comments (0)