Tutorial: Turning Twine into Steam

Last Updated: 7 January 2024

I (Luciana) spent far too much time looking for an up-to-date tutorial on how to turn a Twine game (HTML/Javascript) into a functioning .exe game, and then how to get that game talking to, and published on, Steam. Since I couldn't find one -- well, I made one. Hopefully it will help the next person do the same with much less frustration.

This tutorial is aimed at non-programmers. You don't need to know what Electron is, how it works, etc. If you're a more tech-savvy type who feels this is overly-verbose and dumbed down... well, you're probably not my target audience. Skim it for what you need and move on.

This tutorial is aimed at making games for Windows, since that's the platform I use. You can probably use it to make Mac and Linux versions of games, but you'll need to review the documentation and adjust as necessary.

My thanks to Cyrus Firheir and Greyelf of the Twine Discord for their reviews and suggestions on improving this guide.


Table of Contents

Why Bother?

Honestly, if you're reading this tutorial I assume this is not a question you're asking yourself. You've already decided to do it! But many of the forums, threads, etc. that I found online where people asked "how do I...?" were answered with a blunt "don't bother", "you don't", or "it's not worth the effort."

Whether it's worth the effort depends on a lot of things, but let's not shy from the fact that Steam is the biggest single game publishing platform for PC games right now. The visibility boost and market availability alone means that you'll likely increase your players and your sales. For the lucky few, this might translate to financial success. For others, it might be less about 'getting rich' and more about the milestone: you have a game on Steam!

In our case, it was more about the latter: the ability to wrap up Paradise Inc and, in our own minds, put a fantastic and meaningful capstone on the journey from "we have no idea what we're doing" to "we are indie game devs who are published on Steam". And since we plan on making more games, and also putting those on Steam, learning how it all works was a necessity.

The problem I ran into was not that there weren't tutorials. There are several, but for me they all had two basic problems:

  1. They relied on outdated technology, such as the Greenworks library, or... (Edit: Greenworks was updated in Nov 2023 and is now compatible with the latest version of the Steam SDK, so it should work. However, it is a lot more work to get started with, because Greenheart doesn't provide a prebuilt version for Electron.)
  2. They assumed you already know how to wrap a game in Electron, and only needed help integrating Steam

But if you don't know what Electron is, what npm is, how to install modules, what a package.json file is or what it does -- those tutorials won't help you. And with Twine in particular, the whole goal of the system is to make game development accessible without knowing how to code, so a tutorial aimed at non-programmers seems like a useful thing.

Twine App Builder

If you don't need Steam achievements/integration, and you JUST need your game turned into an .EXE so Steam will publish it, I highly suggest you use Lazerwalker's Twine App Builder instead of this tutorial. It works perfectly fine and exactly as advertised, with no technical knowledge or coding necessary. The only downside to Twine App Builder is that with simplicity comes sacrifice. It does not contain any ability for your game to "talk to" Steam, which is necessary if you want to have Steam track player stats and achievements. But if you don't care about that and you just want it to run -- go give Lazerwalker's app a try.

Assumptions I've Made, and Things You'll Need

For the purposes of this tutorial, I assume that...

  • You're using Windows 10 or 11, with administrator rights
  • You're familiar with installing standard applications, and with how to use a command prompt
  • You're using a text editor such as Notepad++ or Visual Studio Code (I'm using Notepad++)
  • You're familiar with very basic Javascript, and/or...
  • You're good at following directions

That's essentially it. You'll need other things along the way, but I'll show you how to get those as we progress. The 'following directions' thing is the most important. I don't expect you to understand everything in this tutorial, but hopefully these steps are clear enough to get you most of the way there regardless.

There is one more assumption: that you already have a Steamworks account and have created your application. In short, you need to have your Steam app ID handy.

Step 1: Install NodeJS and Git

The trick about HTML-based games is that they are webpages, and require a browser to play them in -- and the problem with putting them on Steam is that Steam doesn't let you upload just HTML. Steam wants an EXE (and webpages aren't EXEs). Fortunately, there's a Javascript library that helps you turn your HTML file into an .exe file: Electron.

Electron creates a bunch of framework and fancy programming around your HTML file that does two things that are absolutely vital to making this work: it creates a very basic, portable web browser, and it takes this web browser and your HTML file, and packages it into a single .EXE file. When you click on the .EXE file, it unpacks the web browser behind the scenes, loads your HTML file into it, and displays it -- which is exactly what we want. And since it's a single .EXE file, that's exactly what Steam wants.

So, Electron. We... aren't installing it, actually. We will use it, though, and before we use it, we need to other things first so it will work correctly: NodeJS and Git. First things first: head over to NodeJs.org and download whatever the 'Recommended for Most Users' is (as of this writing, it's 20.10.0). Install it like you would any other program, accepting whatever it recommends as the default settings. We don't care how it's configured, only that it's installed. Once it's installed, verify it by opening up a command prompt and typing the following commands:

  • node -v
  • npm -v

They should spit out some version numbers. If they don't, restart your computer and try again. If they still don't, you can't proceed until you get Node properly installed.

Once Node is installed and responding, we need to install Git. Visit Git's website and get 'Git for Windows' setup file. (You probably want the 64-bit one.) Download it, install it, and accept most of the defaults. There are two things you might want to change:

  1. 'Choosing the default editor'. Not that many people use Vim nowadays in my experience -- you might want to change it to something else.
  2. 'Configuring the terminal emulator to use with Git Bash'. Flip this to 'Use Windows' default console window' unless you have good reason not to.

Done? Type git at your command prompt. If it comes back with 'git' is not recognized, then restart your computer and try again. If you get a screenful of information about how to use git, then we're good to continue.

Step 2: Setting Up The Project

Now that we have our prequisites installed, we can actually use Electron to create the framework for our game. We aren't installing Electron itself, but we will be using it. Installing something with NPM uses npm install, and downloads all the files to your local computer. We'll be using npm init, which lets us download the code, use it, and then get rid of it without having it sit around permanently. Think of it as the code equivalent of running something from a USB stick instead of actually installing it.

Back to your command prompt! Decide on the folder name you want to use for your game, and run the following commands:

  • npm init electron-app@latest my-game-name

Wait! How do I open a command prompt? How do I get to my folder? - This is part of being familiar with how to use a command prompt. You'll need to get more familiar with these things on your own before continuing (and I would not recommend continuing, if you're having trouble at this point).

If you got a little list of green checkmarks, you're ready to go. If not, something went wrong; backtrack, make sure you have things installed, reboot, and try again.

Now, go find your game folder you just created, and open the forge.config.js file in your text editor of choice. We're going to edit some things. Basically, we're removing everything we don't need, and paring this down to the basics. Feel free to copy-paste this in, changing the name and version as appropriate. Don't worry about the icon yet; we'll get there.


module.exports = {
  packagerConfig: {
	name: "Mything Link",
	executableName: "MythingLink",
	appVersion:"1.2.3",
	icon: "./icon.png",
  },
  makers: [
    {
      name: '@electron-forge/maker-zip'
    }
  ]
};

Save it, close it, and open up the package.json file. We're editing that as well. You'll notice it's tried to fill in the game name, version, and your information as the author already -- you'll probably need to edit those, because they're probably not 100% correct. Once you've corrected them, we need to remove all the things we aren't using. Note that the author name and mail aren't required, and will be hidden anyways (for your especially privacy-minded people out there).

Go down to where you see dependencies and remove the line for electron-squirrel-startup entirely. Then, go to down devDependencies and remove the lines that reference maker-squirrel,maker-deb, and maker-rpm. You should have something like this left over:


{
  "name": "mything-link",
  "productName": "mything-link",
  "version": "1.2.3",
  "description": "The Myth of Link",
  "main": "src/index.js",
  "scripts": {
    "start": "electron-forge start",
    "package": "electron-forge package",
    "make": "electron-forge make",
    "publish": "electron-forge publish",
    "lint": "echo \"No linting configured\""
  },
  "keywords": [],
  "author": {
    "name": "Luciana",
    "email": "[email protected]"
  },
  "license": "MIT",
  "dependencies": {
  },
  "devDependencies": {
    "@electron-forge/cli": "^7.2.0",
    "@electron-forge/maker-zip": "^7.2.0",
    "@electron-forge/plugin-auto-unpack-natives": "^7.2.0",
    "electron": "28.1.0"
  }
}

Done!

With that part, at least. We still have some work do to, but we'll come back to this. For now, notice the main line? It points to src/index.js, and if you go actually look inside your project folder, you'll see it already has an src folder and an index.js inside it (along with a few other things). Remember that, because after a short detour, we'll be revisiting it.

Step 3: Install Steamworks.js

Steamworks.js is not the same as what most people call 'Steamworks' (the Steamworks SDK provided by Valve). Steamworks.js is Javascript library created by Ceifa that serves as an Electron-friendly alternative to the Greenworks library. Both Greenworks and Steamworks.js do the same thing: they let our game's Javascript talk to the Steam client running on your computer so that we can do things like trigger Steam achievements. Greenworks is up-to-date as of November 2023, but more complicated to get working. Steamworks.js was last updated in August 2023 (as of this writing), and mostly works... but, has developed a few issues that we need to work around.

Go back to your command prompt, and make sure you're inside your game directory. By now this command structure should be getting familiar.

  • cd mything-link
  • npm install steamworks.js

If you go look at your package.json file again, you'll notice a new line for Steamworks.js under the dependencies section. If it's there, we're good to go!

Step 4: Add Your Game Files & Edit the Index.js

We (finally) have all our various prerequisites in place. Now we can start adding our game files and actually do things.

Add Your Game Files

Head into your project folder, and into the src subfolder. This is where you're going to add your game content. There are two files in there that you'll want to delete: index.html and index.css. These are just placeholders, so get rid of them and add your own content instead.

Depending on how complex your game is, you might decide to organize your assets in different ways; for the purpose of this tutorial, we'll assume that you have a very simple structure: an index.html file (your own, not the placeholder) and a folder called Assets that contains everything else (graphics, CSS files, Javascript, etc.) Make sure that you include all of your game files: this is going to be the master source for everything when we build your game for Steam. You should have something that looks like this inside your src folder:

Good? Great. Now we need to edit the index.js file. This is a Javascript file that's going to be the actual file that Electron uses for all its instructions, and where we're going to have to do a lot of configuration to get things talking properly. There are ways to make this fancier and just various options -- feel free to research it and changes things after you get the basics working.

Almost everything we're doing is based off the Electron Quick Start Guide, so don't be afraid to go reference that if needed.

Edit the Index.js

Open up the index.js file. You can do this in any text editor, but make sure that you give it a .js extension, not a .txt extension (or anything else). For ease of packaging, I've included my own index.js (as a .txt file, so it doesn't actually execute) that you can download and copy-paste into yours. Do that, and then we'll explore what this does.


const { app, BrowserWindow, screen, ipcMain } = require('electron')
const steamworks = require("steamworks.js")
const path = require('node:path')

These set some very basic, very vital variables in your index.js.

The first one pulls from the electron module we installed, and imports a few built-in things ('app', 'BrowserWindow', 'screen', and 'ipcMain'). The 'app' module represents your actual game (your application), while 'BrowserWindow' is the built-in browser we talked about earlier that will actually display your application. 'Screen' is your computer screen, and we're using that to figure out what size to make the window. 'ipcMain' is an object which allows the Javascript outside your game (the 'main process') to communicate with the Javascript inside your game (the 'renderer process').

The second line pulls in Steamworks.js so we can use it with the short name of 'steamworks', and the third line ('const path') tells Electron to make sure it knows where NodeJS lives. (Remember NodeJS? We downloaded it at the very beginning.)


const createWindow = () => {
  const { width, height } = screen.getPrimaryDisplay().workArea;
  const mainWindow = new BrowserWindow({
	title:"Mything Link 1.2.3",
    width, height,
	menuBarVisible: false,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      devTools: false,
	  contextIsolation: true,
      nodeIntegration: true
    },
  });
  mainWindow.setAspectRatio(16/9);
  mainWindow.loadFile(path.join(__dirname, 'index.html'));
};

A larger block here, but most of this is doing exactly one thing: creating a browser window. We give it a title, we set its width and height (setting it to the entire area of their currently active screen), as well as hide the menu bars to present a sleeker appearance so it looks more like an application and less like a web browser. The 'webPreferences' section is a bit less intuitive. It tells the script to load another script file -- the 'preload.js' file. We haven't made that yet, so if we were to run this right now, it wouldn't work. The devTools: false tells it not to display any debug tools, 'nodeIntegration' tells it we do want NodeJS built-in, and 'contextIsolation' is a security setting that keeps different scripts separated from each other.

Important: Steamworks.js documentation says to set contextIsolation: false; however, I was unable to get Steam integration to work with it set to false. This appears to be an issue in Steamworks.js itself (see Issue #121). Steam integration will work with the value set to true -- if we use the preload.js script, which is why that's in there.

In the last two lines, we set the aspect ratio, so things stay appropriately scaled if the user resizes things -- if you want users to have more flexibility, remove that. Lastly, of course, we have to tell the script which webpage to load: our index.html file.


// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.whenReady().then(() => {
  createWindow()

  app.on('activate', () => {
    // On macOS it's common to re-create a window in the app when the
    // dock icon is clicked and there are no other windows open.
    if (BrowserWindow.getAllWindows().length === 0) createWindow()
  })
})

// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit()
})

The 'app.whenReady' line tells the script (once it's ready) to create the actual browser window that we defined above, with some special instructions for Mac, and the last little bit gives some special instructions for Mac to quit the app then the user closes all the windows.

You'll notice in my index.js I have some Steam-specific coding at the bottom. You can go ahead and copy it into yours, but we'll come back to that to explain it, later.

Add Steam Integration to Your Game

This section makes a very large assumption about your Twine game: that you're using the Sugarcube story format. It's what I helped make Paradise Inc. in, and I've never used other formats like Harlowe or Chapbook. If you're using another story format, the principles hold, but you'll need to research the proper way to call Javascript inside your passages.

Wherever, and however, you decide to call your Steam code, I highly advice you wrap it in a sanity check:


if (window.steam) {
	// do whatever here
}

Once we get everything finalized, this window.steam will represent the Steam client actually being open and active on the player's computer. After all, there's no point in trying to tell Steam to do something, if Steam isn't even installed.

The 'do whatever' is where it gets interesting. This is where you're going to put your code. I was only worried about achievements, so my code focuses on that. Here's a snippet from our Twine game (again, using SugarCube formatting):


<<script>>
if (window.steam) {
window.steam.activateAchievement(variables().args[0]); 
}
<</script>>

We're calling this inside a widget called 'SetAchievement', which passes in an argument. For example, <<SetAchievement 'YouWin'>>. If the player has Steam open, it attempts to reach out to Steam and tell Steam to activateAchievement('YouWin'). You can do this with just about anything, and there's no need pre-define any functions of anything. What is vitally important, though, is that you remember what you're telling Steam to do. In other words, write down that activateAchievement() part -- you're about to need it.

The Preload.js

Just like we did for index.js, we're going to open and edit preload.js. Unlike the index.js, this one is stupidly simple. Here's the entire code for Paradise Inc.:


const  { contextBridge, ipcRenderer } = require( 'electron' );

contextBridge.exposeInMainWorld( 'steam' , { 
  activateAchievement(name) { 
    return ipcRenderer.invoke( 'activate-steam-achievement' , name);
   } ,
  clearAchievement(name) { 
    return ipcRenderer.invoke( 'clear-steam-achievement' , name);
   }
 });

Notice immediately that our Steam command from our game, activateAchievement is back. The preload.js creates and hooks into a sort of 'dummy object' (we're calling it 'steam' because we're using it to talk to Steam) that we can define commands for (like activateAchievement). When we issue that command, it gets passed over to preload.js, which then recognizes them (because we put them in there explicitly). If we tried to do anything else with Steam, even if it was a perfect valid Steam command (say, GetPlayerSteamLevel), Preload.js would completely ignore it and nothing would happen. If you want it to actually do something with Steam, it needs to be added to Preload.js.

Once Preload.js receives a command it recognizes, it takes the next step. In the case above, it reaches out to index.js and tells index.js to run activate-steam-achievement. Again, this doesn't have to be named 'activate-steam-achievement'. The code doesn't care, but I found it easier for myself to remember which part of the chain I was working with, by having slightly different names. So, name it what you want, jot it down so you remember it, and let's move on to index.js.

(As a sidenote: if we could set that contextIsolation to false, we wouldn't need this at all. However, because it's set to true, every possible little bit of code is safely isolated from everything else, and we have to build bridges between them. This is one more necessary piece of bridge. You can really go for a deep dive into why all this is necessary, complete with graphics, with Debug & Release's Ultimate Electron Guide.)

Add Steam to Your index.js

We're almost there! Go back to your index.js, and the Steam-specific code we added there (or, add this if you haven't):


// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and require them here.

const steamClient = steamworks.init(000000)

// /!\ Those 3 lines are important for Steam compatibility!
app.commandLine.appendSwitch("in-process-gpu")
app.commandLine.appendSwitch("disable-direct-composition")
app.allowRendererProcessReuse = false
steamworks.electronEnableSteamOverlay(false);

The 'steamClient' line should specify your unique Steam app ID. You'll need to get this from your Steamworks dashboard, after you create your application. The 'important three lines'... honestly, I'm not sure if these are still needed, but I took them from Liana P's How to integrate an HTML5 Electron based game with the Steam API. Some research indicates that back in ~2020, the Steam overlay would not work on Electron games without the in-process-gpu and disable-direct-composition flags, but I didn't test to see whether this is still the case. Note that while most of Liana's tutorial is still valid, although she does have the contextIsolation set to false (which no longer works), and she mentions a UI overlay issue that I never encountered.

Speaking of the SteamOverlay -- that's the last line up there, which turns it off. You could turn it back on by setting the value to true if you want; I didn't need it (and it's not necessary for calling achievements and such, which was my main goal).

Now the very important things. We already told our game what commands to give, and we told our preload.js what to do when it receives them: to tell index.js to do something else. But we haven't told index.js what to do! Let's fix that.


// Create a port for calling achievement acquisition from within the game 
  ipcMain.handle( 'activate-steam-achievement' , (_event, name) => {
    steamClient.achievement.activate(name);
  } );
  
  ipcMain.handle( 'clear-steam-achievement' , (_event, name) => {
    steamClient.achievement.clear(name);
  } );
 

At this point, the name of the command absolutely matters and must be correct. You are now talking directly to the Steamworks.js library, and you must use the commands it understands. In this case, it must be achievement.activate. How do we know that? Because it's documented in the Steamworks.js declarations file. Now our chain is complete: our in-game Javascript uses the dummy object 'steam' and the dummy command 'activateAchievement', and pipes it over to preload.js. There, we told preload.js that those commands REALLY mean to reach out to index.js and tell it to 'activate-steam-achievement'. index.js receives that, recognizes it, and tells Steamworks to issue the actual underlying command to the Steam client.

At this point, everything should be integrated, with one large caveat. Take a moment to look at how this command flow works between the different layers:

Notice the arrow is only going one way? We're talking to Steam, but we don't care what Steam says back (not for achievements, at least). If you want this to be two-way communication... well, good luck. That's beyond the scope of this tutorial.

Building Icons

What's a game without an icon? Electron will give you an icon, but it will be the very generic 'Electron' icon, and it won't be the wonderful icon you've made for your game. What? You didn't make a custom icon? If not, go do that, and we need it in a specific size: 1024x1024, at a 1:1 aspect ratio. Yes, that's huge, but the piece of software we're going to use to make all our different icon sizes, wants a 1024x1024 file size to start with. Place this icon in your project folder -- NOT the src folder -- and just call it 'icon.png' for right now.

Once you're done, go back to your command prompt and install Icon Builder.

  • npm install --save-dev @uosjs/icon-builder

Go back to your command prompt, and (as usual) make sure you're in your game project folder. Also make sure this is where your icon.png file is. Then run this command:

  • "./node_modules/.bin/uosjs-icon-builder" --input=icon.png --flatten

You should get a lovely block of text indicating it's transformed your 1024x1024 PNG file into a bunch of different sizes, with a helpful "ALL DONE" at the end.

The Final Result

Ready for a dry run? First, before we actually turn this into an .exe, we're going to make sure it works. Save everything, and go to back to your command prompt. Time to test.

  • npm start

You should get something very similar to the screen below... and then your game should open! If you get any errors, make sure that you didn't copy-paste something wrong, that you aren't missing commas or quotation marks, etc. In particular, if you get Error: failed to init the steamworks API, you probably forgot to change the zeros to your unique Steam App ID in the index.js file.

Now that we know it really does work... we'll turn it into a real, working .EXE file. Close your game, and go back to your command prompt one more time.

  • npm run make

By default, this will attempt to create an executable that matches your actual operating system -- so if you're doing this on Windows, you'll get a Windows executable; if you're on Mac, you'll get a Mac one, etc. How long it takes will depend on the size of your game: if you have a lot of various assets that need to be packed up, then it will take longer to pack them. In the end, you should get something like this:

Now, go look in that folder. Under out/<your-game-name>/-win32-x64, you should find all of the contents of your game. You'll also find an actual installer/Setup file under out/make/, but don't use that. Steam itself will handle the install/uninstall of your game, and while Electron is a lovely thing, using the Electron-created installer tends to trigger false positives from Windows Defender anti-virus, and the last thing you want is for people to think your game is a virus.

So now we have our completed game! Now you just need to upload it to Steam...

Fortunately, that's easier said than done, for the most part. While you'll still need to register as a Steam partner, create an app, and configure your store page (which is not very difficult, but it is very time consuming), actually uploading the files you've generated is fairly easy thanks to a program called SteamPipe.

Rather than duplicate effort here, I will point you to AuroDev's "How to Upload a Game to Steam" on Youtube. At about the 5 minute mark, when he talks about building the game and copying all the file into the game folder -- this documentary has taught you how to build the game, and given you the files you'll need to copy in there.

That's all, and good luck!