How to integrate an HTML5 Electron based game with the Steam API (alternative to Greenworks for Steamworks api)
Liana P
Published on July 17, 2022This weekend I added a Steam integration for narrat, my narrative RPG game engine. Narrat is an HTML5 web-based engine which can build to a native game using Electron, so I needed a way to integrate Electron with Steam using their Steamworks API.
It was very hard to find good info on this and the actual solution ended up being buried in a lost Reddit post, so I’m writing this article to hopefully make it easier for people to find out how to do this in the future.
Greenworks (and why you shouldn’t use it)
It would be a lot of work to build an integration from scratch, as it would involve writing a native module for node.js to bind the Steamworks API. So instead I googled around for existing integrations, hoping someone would have done the hard work.
I eventually found Greenworks, an integration built Greenheart Games, the devs of Game Dev Tycoon. It seems decent, the problem is it hasn’t been updated in years. At first I couldn’t find any alternatives so I figured I’d spend a few hours trying to get it to work, doubting I would.
There are a few problems with using Greenworks:
- It hasn’t been updated in years
- It uses pre-built binaries that need to be built for a specific version combination of electron/greenworks/node/steamworks API
- It’s not really well documented with most of the resources being outdated and contradicting each other
- It uses older versions of the Steam API
- There is no open source example of anyone getting it to work in recent years.
- It has a workflow which is very anti idiomatic as it requires editing files in
node_modules
to set it up.
I’ll skip the details, but it involved chasing specific binaries and trying different sorts of combinations of folder structures versions of various tools, never really getting any success. Along the way I found this recent article from a studio that used Greenworks to publish an HTML5 game on Steam, so it must be possible to get it to work. Sadly, the article gives a summary of what they did, but not much info on how to do it.
I also found this Github gist with more detailed instructions from someone who used Greenworks.
Either way, after hours of trying everything I could think of, I figured this was a waste of time, so I went back to googling more deeply.
Steamworks.js, built in Rust
Luckily I finally found something. Steamworks.js is a different implementation of the Steamworks SDK for HTML5 node.js games. It’s written in Rust and it actually works.
For some reason this library has gone completely under the radar, and I only found it from a Reddit thread by the creator that went ignored. I really want to thank the people who made this library because it’s great and with Greenworks being effectively dead and almost unusable, very needed.
Video of the narrat template running in Steam
How to integrate Steamworks.js in electron
It was pretty easy to integrate Steamworks.js into narrat. I (almost) didn’t hit any issues and got running in a few minutes. Following the instructions on the GitHub will generally work, but here’s what I did to integrate it with narrat:
- Install Steamworks.js:
npm install steamworks.js
- Edit
electron-main.js
to add code to load Steamworks:
// Set this to true if building for steam
const useSteam = false
if (useSteam) {
const steamworks = require("steamworks.js")
console.log("stesmworks, ", steamworks)
const client = steamworks.init()
console.log("client", client)
console.log(client.localplayer.getName())
// /!\ Those 3 lines are important for Steam compatibility!
app.commandLine.appendSwitch("in-process-gpu")
app.commandLine.appendSwitch("disable-direct-composition")
app.allowRendererProcessReuse = false
}
I’ve added a variable to toggle Steam usage, as some people might want to create a DRM-free build not using Steam. I’ll eventually update this code to use an environment variable on build.
At this point Steamworks is loaded into the electron app, and this is technically enough to get Steam integration as it makes the Steam overlay work.
- To access Steamworks on the client:
I haven’t tried yet accessing Steamworks from inside the client. The readme on the Steamworks github mentions that it should work as long as the correct options are passed to the browser window to have node integration. Here’s a paste of the relevant code in my app.
function createWindow() {
// Create the browser window.
const mainWindow = new BrowserWindow({
width: 1280,
height: 720,
resizable: false,
fullscreenable: true,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, "electron-preload.js"),
// /!\ Those two options are needed for Steamworks to behave
nodeIntegration: true,
contextIsolation: false,
},
})
// and load the index.html of the app.
mainWindow.setAspectRatio(1280 / 720)
mainWindow.loadFile("build/index.html")
// Open the DevTools.
// mainWindow.webContents.openDevTools()
}
This should make it possible to use const steamworks = require('steamworks.js');
in the client. If it doesn’t work, it would still be possible to simply write functions to interface with it in the electron scripts, so this shouldn’t be an issue either way.
You would only need to require steamworks in the client if the game needs to use specific Steam features, for now I’m happy with the engine loading in Steam properly with the overlay.
First gotcha: Fixing the overlay
One problem with using the Steam overlay in electron is that Electron causes weird issues with screen repainting. Essentially, if the screen isn’t being refreshed every frame, the overlay won’t refresh so it will look frozen or have graphical glitches.
This isn’t a problem if your game is running a fullscreen canvas that refreshes every frame, but narrat is a UI-driven engine and doesn’t actually have a gameloop as it’s essentially a static web page.
I initially tried to fix it by having a 1 pixel large canvas on the page refreshing every frame, but that wasn’t enough. It made Electron repaint the frame, but only around the canvas. I even tried having the canvas travel randomly around the page every frame, but it still looked very glitchy.
Once I realised chromium only repaints the area around the canvas that got changed, I figured what I need is something covering the whole screen refreshing every frame.
So I created a SteamPlugin
for narrat (yes, narrat has a plugin system). The plugin basically creates a canvas on top of the screen, then launches a gameloop to clear it and refill it with transparent content, to force a screen refresh.
// Enable this when releasing for steam
const useSteam = false
class SteamPlugin extends NarratPlugin {
onNarratSetup() {
console.log(
"Loading steam plugin - Creating a game loop to force screen refresh"
)
const canvas = document.createElement("canvas")
canvas.id = "fake-refresh-steam"
canvas.width = 1
canvas.height = 1
const styler = canvas as any as HTMLDivElement
styler.style.position = "fixed"
styler.style.top = "0px"
styler.style.bottom = "0px"
styler.style.pointerEvents = "none"
styler.style.zIndex = "30000"
document.body.appendChild(canvas)
fakeGameloopForSteam()
}
}
function fakeGameloopForSteam() {
const canvas = document.getElementById(
"fake-refresh-steam"
) as HTMLCanvasElement
const styler = canvas as any as HTMLDivElement
styler.style.width = `100vw`
styler.style.height = `100vh`
const ctx = canvas.getContext("2d")!
ctx.clearRect(0, 0, 1, 1)
// Not sure if this could work if fully transparent or if setting CSS opacity to 0, I haven't tried yet
ctx.fillStyle = "rgba(0, 0, 0, 0.01)"
ctx.fillRect(0, 0, 1, 1)
requestAnimationFrame(fakeGameloopForSteam)
}
The onNarratSetup
function gets called by the narrat plugin system, setting up the Steam fake gameloop to place that canvas on the page and force a refresh every frame, which fixes the overlay.
This will also need to be updated to use an environment variable later, but for now it’s good enough for people to use.
Last gotcha: The steam-appid.txt file
Steamworks.js expects a steam-appid.txt file at the root to know which steam app is running. There is also a way to pass it as an argument when initialising it, so this could be solved with environment variables too.
For narrat, I chose to have this file at the root of the repo, as that’s easy for people to understand and edit. The only problem is that when actually packaging the game with electron, this file doesn’t get brought in. If you try running a built game in Steam and the app id is missing, it will crash on launch.
So I simply updated the final packaging script to copy that file over to the build.
"package": "electron-forge package && shx cp steam_appid.txt out/narrat-template-win32-x64/steam_appid.txt",
Eventually, this needs to be updated to support other environments than windows. Most people will want to use Windows as a priority for Steam though. I will probably add different scripts to package per platform, or maybe a clever script that guesses which one to use.
Running the game
Steam needs to be running for games built with Steamworks to run.
There is a default appId to use for Steam development (480), so this value can be put in the steam_appid.txt
file in the repo to get a working Steam app.
Then it’s a matter of packaging the game, and running it. Another possibility (which can fix some integration glitches) is to go in Steam and add the game as a “non-steam game” by browsing to the built .exe file. This allows actually launching the game from Steam.
Final result in the narrat template
This whole integration is now merged into the narrat template, which is a template folder used by the create-narrat tool which generates new narrat projects. It has everything setup to build a game with narrat and electron, and now also contains the code explained above to integrate with Steamworks. New users would just have to enable the steamWorks variables and edit the steam_appid.txt
file to be able to export their game to Steam!