How to make your game offline-capable the easy way

As kind of a side effect of making my own IF engine, I made 2 small JS files that can easily make a website available offline: (5,8 KB)

How to use

service.js has to be put in the same directory your main HTML file is in, updater.js has to be included in your HTML file as a script tag, e.g. by putting it in the Twine user JS section.

In the same directory also has to be a config.json file for configuring what should be saved for offline use:

    "version": 1,
    "cacheFiles": [
    "slashIndex": true

version has to be incremented on each update you release, so the new version will be downloaded.
cacheFiles contains the URLs of all things you need cached, relative to the directory the config.json is in. It should work with URLs to other sites (e.g. for image hosting), but I haven’t tried that.
slashIndex is only required to map to, but it also shouldn’t break anything to just have it.
Note that config.json has to be a valid JSON file, otherwise the offline capability won’t work for new users and existing users won’t get the update.

Here is an example where I made the Chapbook version of Cloak of Darkness offline-capable: (204,2 KB)

The update notification displayed to users is by default on the bottom right corner, rounded edges, golden background, black text. The positioning element has the CSS class iff-update and the main notification iff-container, so you can style it yourself if you want.


I’m confused, doesn’t just saving the webpage like normal (with right click or ctrl s) work for saving twine games? They’re html files. Any assets are automatically put into another folder (ime, with firefox).

1 Like

That only works on desktop, and there are some restrictions that apply (e.g. to play Trigaea on my phone during a train ride I had to download the archive and start a local webserver using a CLI app). A service worker was literally designed for this, and requires no user interaction at all, and works nicely on all devices.

Aster F,

Exactly what is the procedure that you use?

My experience has been that selecting the RMB “save page as” option with a Twine (TweeGo) game that’s currently open in a browser does not save the associated Javascript that’s in a [script] passage. As a result, clicking on the resulting .htm file fails such that selecting a link to a macro in the IF does nothing.

If I have the IF’s URL, though, “save link as” does work for me.

I tested it just now with a story that I’ve been working on (compiled with TweeGo, though, not with Twine) and viewing in Firefox 125.0.2 under Windows 10 Pro. It’s compiled into a single .html file with no separate asset files. Only the single non-functional .htm was created by “save page as”. No other files or folders were created anywhere on the computer. I used the 3rd party utility Everything to look for all of the files created at the same time. There was only the .htm file, which (to my surprise) actually was 30% larger than the original .html file.

I’ve tested this in the past with IFs that I’d uploaded to with the same results: the file created by “save page as” fails but “save link as” works.

I think this is same the issue I noticed here - Firefox adds extraneous newlines when saving a full page as html. Apparently saving in “HTML only” mode works fine.

1 Like

But does “HTML only” also download assets used in the page?

No, it doesn’t, but also page assets are not usually saved in “Save full page” anyway, because in twine, the assets are loaded via scripting rather than directly embedded in the HTML.

thanks for clearing up my confusion :+1:

The desktop app allows for “local install” of games, to be played offline (with assets). I say “install” because it will download any HTML game and open it in a “browser wrapper” (through the app), but you can find the downloaded files on you computer, and open the HTML file directly.

Note: the wrapper used by itch so so bad (and old) that it will often mess with custom styling with Twine projects (I’ve had the experience multiple times, where the stylesheet is not completely loaded for some reason, and I still don’t know why - is obviously no help).

The best way to make your game offline-capable is to allow users to download a copy (like a Zip folder) with all the relevant assets (so all local images/sound/fonts).

That process works, but is a lot more complicated on a phone, and for some things you actually need something like a browser wrapper, because when you open a HTML page from the filesystem, loading other files is restricted.

As I said, a service worker works completely transparently (no messing with styles), in the browser directly (and as such also on mobile) and requires no user action.

Do you have the non-minified version available?

Well, yes and no: I have the original source of course, but it’s in Typescript, uses JS module syntax and Svelte, so without a build tool it won’t do much for you. I haven’t checked it into git yet because I have other pending modifications, but here’s an archive: (28,7 KB)

The contents are in src/service_worker/service_dist.ts for the service worker and in src/service_worker/updater_dist.ts for the story JS snippet.
Webpack is configured to build it. I don’t know if you can feasibly build it without minification: I disabled optimizations in webpack and the whole Svelte runtime library got included, even though practically none of it is used.

1 Like


Does cacheFirst() intercept files as they are requested and then cache them?

No, it searches the cache for the file first and then hits the network if it can’t be found in the cache. I wanted it to interfere minimally with general traffic and work deterministically: E.g. a player that is just starting and a player that has already played should have the same experience offline. If the worker would cache every request, it would lead to the file being there for the player who has already encountered that file, but not available to a new player. That would potentially be a nightmare to debug if you aren’t aware of the exact caching.

I could add more caching policies if needed though.

Ahh right, I see it now. I think I was expecting it to actually cache on request as well.

But I see it only happens on install() and recache(). I assume that cache.addAll() does the actual caching then?

1 Like

Yes. Service workers and the Cache API are documented, as are the Broadcast Channels of which I use one to communicate back to the page.