The following is a collection of improvements that can be implemented on a Unity WebGL game released in Itch.io, that allows developers to distribute their game on mobile devices without going through Apple's App Store or Google Play.
This post serves both as a cleaned-up version of my notes for future reference, but it can also help other developers having a similar interest on the subject. Also, while the post focuses on WebGL games created using the Unity engine, the techniques described below can be easily ported to any other engine that supports this in-between JS layer for allowing communication between the game and the hosted webpage.
Implementing what is being described in this post will allow players to install your WebGL game as a Progressive Web App (PWA), creating an icon on their home screen and launching it without opening their browser. Players can also be validated for their purchase via Itch API and can even be notified when an update is available!
Minor disclaimer: I haven't coded professionally in JavaScript for more than a decade, so the JS code you will see below can be quite crude. On the upside, it's just pure vanilla JS so it will be easy to port to any framework you are familiar with.
OK, let's begin!
Before we delve deeper into the technical stuff, let's address one main issue: Itch does not allow you to sell/set a price on a WebGL game. Don't fret though, as there is a way to check whether a certain Itch user has access to your game via download keys.
In the "Distribute" tab of your WebGL game, you can generate a number of download keys and either distribute them freely, or unlock them after users have paid for the game via your website. You can also bundle them together as a free upgrade when users purchase the desktop version of your game right within Itch!
To achieve the latter, you will have to generate a number of download keys for your WebGL game, download them locally and then upload them as "External keys" in the "Distribute" tab of your desktop game (with Key Type set to "Other"). This way, when a user purchases the desktop version of the game, they will be granted with a download key for the WebGL build which they can claim.
Now that we have addressed that, let's proceed!
The first thing we need to establish is that every time you push a new WebGL build, Itch generates a new webpage for that build, for cache related reasons. The existing webpage will continue to work for some time but it will get invalidated a while after the new build becomes available. This means that we need a way of always pointing the player to the latest and greatest build. Although this is not as straightforward, as it requires server-side API call(s), it's definitely feasible.
What we can do is create a webpage hosted on the game's website that serves multiple purposes:
- It's the entry point for the player to install the game as a PWA.
- It always fetches the latest build and also notifies the player if an update is available.
- (Optional) It can validate the player on whether they have purchased the game or not, in order to allow access to the build.
Let's go through the list one-by-one.
1. Generate the PWA page
There is a lot of online documentation for creating a PWA, but here are the tags that need to be included the <head>
of the webpage:
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="<!-- GAME NAME -->">
<link rel="apple-touch-icon" href="<!-- link to app icon -->">
<link rel="manifest" href="<!-- link to the manifest.json file -->">
The manifest.json
file is needed (although not required), in order for the game to be detected as an installable PWA. Most of manifest.json
properties are self-explanatory and extensively documented. The only note I need to make here is to use fullscreen
instead of standalone
for the display
property as this will actually make the web view extend outside the screen safe area when the game is installed. The orientation
and start_url
ones are also important.
2. Fetch the latest build
In order to always fetch the latest build url, we need to use Itch's API. You can generate an API key on the Developer > API keys section of Itch user settings. The issue here is that the API documentation on Itch does not include all of the endpoints: there are endpoints that are not listed there and one of them is the one we need: /api/1/{api_key}/game/{id}/uploads
.
After fetching the ID of the WebGL game from the url of the edit page of the game, we can perform a simple API request on the server that hosts the webpage of our game. Here's a simple snippet that uses server-side JavaScript to perform the request:
const uploadsURL = `https://itch.io/api/1/${process.env.ITCH_API_KEY}/game/${process.env.ITCH_GAME_ID}/uploads`
try {
const response = await fetch(uploadsURL, {
method: 'GET'
});
if (response.ok) {
const uploads = await response.json();
if (uploads == undefined
|| uploads.uploads == undefined
|| uploads.uploads.length == 0) {
return {
statusCode: 404,
body: JSON.stringify({ message: `Upload not found!` })
};
}
const upload = uploads.uploads[0];
const buildID = upload.build.id;
const uploadID = upload.build.upload_id;
const buildURL = 'https://html-classic.itch.zone/html/' + uploadID + '-' + buildID + '/index.html';
return {
statusCode: 200,
body: buildURL
};
} else {
return {
statusCode: response.status,
body: JSON.stringify({ message: `Error fetching uploads ${response.status}` })
};
}
}
This is the main logic of the build url generator: It requests the uploads list from the API for our game, fetches the latest upload object and generates the URL based on the values of the upload and build IDs.
3. Optional: Validating the user purchase
You can go a step further and only allow the user to fetch the latest build url after checking whether they have actually purchased the game, in order to do that you would need to create an OAuth application, allow user to login to Itch using this application and then query two more API endpoints using their access token:
As soon as the user grants permission to the OAuth application and Itch redirects back to our server with the access token, we can perform the following two API requests before trying to generate the build URL: /api/1/{access_token}/me
, where the access token is used as an API key in order to fetch the user ID of that user and /api/1/{api_key}/game/{game_id}/download_keys?user_id={user_id}
, where we expect that a download key will exist for that user, if they have claimed a key for the WebGL game (as we have described above).
And there you have it: We have essentially built a really simple mechanism for checking whether a player has purchased the game and a way to always fetch the latest build url for that game!
With this url we can create an <iframe>
HTML object that hosts this game and load it via JavaScript, as soon as the build url is ready!
The bonus thing here is that we can communicate with the hosted <iframe>
, check when the game is on its 'Main Menu' scene and request the build url again, comparing it with the current one and presenting a pop-up to the user to refresh the website as a new build is available! We will look into how we can communicate with the hosted <iframe>
using the postMessage()
and message
event of HTML below.
Communicating with the webpage
There are multiple cases where the game logic might need to communicate with the hosting page, either to query certain properties of the device (is it a mobile device?), change certain properties of the DOM or even receive events from the webpage.
In order to achieve that we need to create a JavaScript bridge. You can follow Unity's guide for that. You can create a WebGL
folder under the Plugins
directory (Assets/Plugins/WebGL/
) and create two files there:
WebGLPluginJS.cs
WebGLPluginJS.jslib
The .cs
file holds all the [DllImport("__Internal")]
function signatures and the .jslib
contains the JavaScript implementation.
Passing events from game to the PWA webpage
Let's say that we want to inform our webpage that the game's main menu is visible or not.
WebGLPluginJS.cs
[DllImport("__Internal")]
public static extern void MainMenuShown();
[DllImport("__Internal")]
public static extern void MainMenuHidden();
which we can call from our Main Menu's in-game logic like that:
MainMenu.cs
public class MainMenuLogic : MonoBehaviour {
void Start () {
#if UNITY_WEBGL && !UNITY_EDITOR
WebGLPluginJS.MainMenuShown();
#endif
}
void OnDestroy() {
#if UNITY_WEBGL && !UNITY_EDITOR
WebGLPluginJS.MainMenuHidden();
#endif
}
}
The actual JavaScript methods can take advantage of the postMessage
HTML API:
WebGLPluginJS.jslib
MainMenuShown: function() {
parent.postMessage("MainMenuShown", "*");
},
MainMenuHidden: function() {
parent.postMessage("MainMenuHidden", "*");
},
Here we post a message to the parent webpage which is the page the build URL is hosted as an iframe, namely our PWA webpage. On the PWA webpage JavaScript we can have something like that:
window.addEventListener("message", (e) => {
const data = e.data;
if (data == "MainMenuShown") {
// Execute the check for updates logic and
// present the "Update available" popup in HTML
}
else if (data == "MainMenuHidden") {
// Hide the "Update available" popup in HTML
}
},false);
Alternatively, we can reverse the logic and have the game query (using the JS bridge) the hosting page on whether a new build is available and then present an in-game popup.
Visibility change
An easy way to pause the game when user taps outside the WebGL container or navigates to another website, is to use the Page Visibility API.
WebGLPluginJS.cs
[DllImport("__Internal")]
public static extern void RegisterVisibilityChangeEvent();
WebGLPluginJS.jslib
RegisterVisibilityChangeEvent: function () {
document.addEventListener("visibilitychange", function () {
SendMessage("GameLogic", "OnVisibilityChange", document.visibilityState);
});
if (document.visibilityState != "visible") {
SendMessage("GameLogic", "OnVisibilityChange", document.visibilityState);
}
},
In the Unity scene, create a GameObject named "GameLogic" (or change that to whatever you want) and in one of its attached scripts declare the "OnVisibilityChange" method. Additionally call the RegisterVisibilityChangeEvent()
from its Start()
method.
GameLogic.cs
void Start() {
#if UNITY_WEBGL && !UNITY_EDITOR
WebGLPluginJS.RegisterVisibilityChangeEvent();
#endif
}
void OnVisibilityChange(string visibilityState) {
// Custom logic
}
If the game already reacts to the OnApplicationFocus
event by Unity (for example by displaying a pause screen), the implemented OnVisibilityChange()
method can simple call OnApplicationFocus()
directly which will execute the same logic.
GameLogic.cs
void OnVisibilityChange(string visibilityState) {
OnApplicationFocus(visibilityState == "visible");
}
Device orientation detection
Generally speaking, if the game has been created with the option to react correctly to resolution changes and more specifically designed to support both vertical and horizontal orientations of mobile devices, then everything might already be operating correctly.
If, on the other hand, the game supports only a specific orientation, then locking the screen orientation of the screen sounds like the way to go.
Sadly, even though a method for locking the orientation exists in the Screen API, it's not supported yet by mobile browsers. In iOS, for example, this feature is hidden behind the Safari Feature Flags (as seen below), so the only option left here is to inform the player than only a certain device orientation is supported, by detecting and reacting to screen orientation changes.
The following snippet on our PWA webpage assumes that the game only supports portrait orientation on mobile devices:
isMobile = () => {
return /mobile/.test(navigator.userAgent.toLowerCase());
};
isLandscape = () => {
return isMobile() && /landscape/.test(screen.orientation.type.toLowerCase());
};
toggleLandscapePrompt = (enable) => {
// Toggles landscape prompt visibility based on the value of the
// `enable` flag.
};
loadGame = () => {
// Queries our server for the build url and creates the `iframe`
// containing the link to the Itch.io page hosting the Unity WebGL
// game.
};
window.addEventListener("DOMContentLoaded", () => {
// Listen to orientation events
screen.orientation.addEventListener("change", (e) => {
let landscape = isLandscape();
toggleLandscapePrompt(landscape);
if (!landscape) {
loadGame();
}
});
// If the game only supports portrait orientations, do not allow
// it to be loaded if player's device is initially held sideways.
if (isLandscape()) {
toggleLandscapePrompt(true);
}
else {
loadGame();
}
}
Persistent data saving
In WebGL, storing data in PlayerPrefs
will not persist over page reloads. For this to happen, PlayerPrefs
must be replaced with a different way of storing persistent information, which in most cases is the localStorage
API. There's a caveat here: For the approach we are exploring in this article where we create a page that generates an iframe which points to the WebGL build on Itch.io, the localStorage
API approach won't be enough, as it will store the data on the Itch.io page and if player has added the page as an web app to their home screen, this data will not persist over web app relaunches.
Firstly, let's tackle the simple scenario where a PWA webpage like the one we have above does not exist and we only aim to store player preferences persistently when Itch generates a new url because of a new build: For this to happen we use the localStorage
API and we prefix our PlayerPrefs
keys with a unique (to our game) string (PREFIX_KEY
), which will allow our keys to co-exist with any other localStorage
keys that might already exist for the Itch.io domain.
Assuming there's a centralized logic responsible for storing information for your game (for example: high scores, currency etc), the logic can me modified to work differently for WebGL builds.
PlayerPrefsManager.cs
public static class PlayerPrefsManager {
private const string PREFIX_KEY = "<UNIQUE_KEY>";
public static string PrefixKey(string key) {
return PREFIX_KEY + key;
}
public static void SetString(string key, string data) {
#if UNITY_WEBGL && !UNITY_EDITOR
WebGLPluginJS.SaveData(PrefixKey(key), data);
#else
PlayerPrefs.SetString(key, data);
#endif
}
public static string GetString(string key, string defaultValue = "") {
#if UNITY_WEBGL && !UNITY_EDITOR
return WebGLPluginJS.LoadData(PrefixKey(key));
#else
return PlayerPrefs.GetString(key);
#endif
}
// NOTE: This can be extended to `GetFloat` / `SetFloat`, `GetInt` /
// `SetInt` methods.
public static void Save() {
#if UNITY_WEBGL && !UNITY_EDITOR
// no-op
#else
PlayerPrefs.Save();
#endif
}
public static bool HasKey(string key) {
#if UNITY_WEBGL && !UNITY_EDITOR
return WebGLPluginJS.LoadData(PrefixKey(key)) != string.Empty;
#else
return PlayerPrefs.HasKey(key);
#endif
}
}
WebGLPluginJS.cs
[DllImport("__Internal")]
public static extern void SaveData(string key, string data);
[DllImport("__Internal")]
public static extern string LoadData(string key);
WebGLPluginJS.jslib
LoadData: function(yourkey){
var returnStr = "";
if (localStorage.getItem(UTF8ToString(yourkey)) !== null) {
returnStr = localStorage.getItem(UTF8ToString(yourkey));
}
var bufferSize = lengthBytesUTF8(returnStr) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(returnStr, buffer, bufferSize);
return buffer;
},
SaveData: function(yourkey, yourdata){
localStorage.setItem(UTF8ToString(yourkey), UTF8ToString(yourdata));
}
Now, if we want to ensure that the PlayerPrefs
persist not only across builds but also be synced on our PWA webpage, then we need to perform an extra step: When players install the PWA app, whatever is stored in the localStorage
of the hosted <iframe>
of that webpage will not persist, which means that we will want to store the information to the localStorage
of the PWA webpage and not of the iframe. In order to do that we can use postMessage
again to sync the state between the build webpage and our PWA webpage. We can start by storing the PlayerPrefs
on a custom object and when the PlayerPrefsManager.Save();
method is called, sync this object via postMesage
, to the parent PWA webpage. We can also add an optional #installed
hash in our generated build url which can differentiate between the webpage being hosted on the game website on Itch or on our PWA webpage where we fully control the logic.
Here is how the code can be modified to account for that:
PlayerPrefsManager.cs
public static class PlayerPrefsManager {
public static void Save() {
#if UNITY_WEBGL && !UNITY_EDITOR
WebGLPluginJS.SyncToParent();
#else
PlayerPrefs.Save();
#endif
}
}
WebGLPluginJS.cs
[DllImport("__Internal")]
public static extern void SyncToParent();
WebGLPluginJS.jslib
LoadData: function(yourkey){
var returnStr = "";
if (window.location.hash == "#installed") {
if (window.parentLocalStorage[UTF8ToString(yourkey)] != undefined) {
returnStr = window.parentLocalStorage[UTF8ToString(yourkey)];
}
}
else {
if (localStorage.getItem(UTF8ToString(yourkey)) !== null) {
returnStr = localStorage.getItem(UTF8ToString(yourkey));
}
}
var bufferSize = lengthBytesUTF8(returnStr) + 1;
var buffer = _malloc(bufferSize);
stringToUTF8(returnStr, buffer, bufferSize);
return buffer;
},
SaveData: function(yourkey, yourdata){
if (window.location.hash == "#installed") {
window.parentLocalStorage[UTF8ToString(yourkey)] = UTF8ToString(yourdata);
}
else {
localStorage.setItem(UTF8ToString(yourkey), UTF8ToString(yourdata));
}
},
SyncToParent: function() {
if (window.location.hash != "#installed") {
return;
}
parent.postMessage({ 'localStorage': window.parentLocalStorage }, "*");
}
and on our PWA webpage we can modify the message
listener to also listen for the 'localstorage' messages and update its own localStorage
accordingly.
window.addEventListener("message", (e) => {
var data = e.data;
// ...
else if (typeof data == "object" && data.localStorage != undefined) {
for (const [k, v] of Object.entries(data.localStorage)) {
localStorage.setItem(k, v);
}
}
},false);
We also want our PWA webpage to sync its contents with the game from its localStorage
when the game loads. We can add the following logic when constructing the <iframe>
to be added to our webpage and before inject it:
// assuming that we have constructed the build_url
var iframe = document.createElement('iframe');
iframe.setAttribute('allowtransparency', 'true');
iframe.setAttribute('webkitallowfullscreen', 'true');
iframe.setAttribute('mozallowfullscreen', 'true');
iframe.setAttribute('msallowfullscreen', 'true');
iframe.setAttribute('allowfullscreen', 'true');
iframe.setAttribute('frameborder', '0');
iframe.setAttribute('scrolling', 'no');
iframe.setAttribute('allow', 'autoplay; fullscreen *; geolocation; microphone; camera; midi; monetization; xr-spatial-tracking; gamepad; gyroscope; accelerometer; xr; cross-origin-isolated; web-share');
iframe.id = 'game';
iframe.src = build_url + '#installed';
iframe.onload = () => {
let storageDict = JSON.parse(JSON.stringify(localStorage));
document.querySelector('iframe').contentWindow.postMessage({
'localStorage': storageDict
}, '*');
};
document.body.appendChild(iframe);
On the HTML file that Unity generates that actually hosts our game we can add the following:
// Sync localStorage from parent frame
document.addEventListener("DOMContentLoaded", () => {
window.parentLocalStorage = {};
window.addEventListener("message", (e) => {
let data = e.data
if (typeof data == "object" && data.localStorage != undefined) {
for (const [k, v] of Object.entries(data.localStorage)) {
window.parentLocalStorage[k] = v;
}
}
});
});
Bonus: Update the background color of the hosted webpage
Given that we now have an easy JS bridge between our game and the build / PWA webpages, we can have some fun as well! One cool thing we can implement is a way to change the background color of the hosted webpage in order to match the color of the scene! You can just change the background color of the build webpage or propagate the change all the way to the PWA webpage.
GameLogic.cs
public void ChangeBackgroundColor(Color color) {
#if UNITY_WEBGL && !UNITY_EDITOR
WebGLPluginJS.ChangeBackgroundColor(ColorUtility.ToHtmlStringRGB(color));
#endif
}
WebGLPluginJS.cs
[DllImport("__Internal")]
public static extern void ChangeBackgroundColor(string hexCode);
WebGLPluginJS.jslib
ChangeBackgroundColor: function (hexCode) {
var hexCodeString = UTF8ToString(hexCode);
document.documentElement.style.backgroundColor = "#" + hexCodeString;
document.body.style.backgroundColor = "#" + hexCodeString;
},
and there you have it!
More ideas
Given that a PWA is essentially a webpage, we can leverage more APIs to improve the user experience. Things like service workers to perform tasks in the background, offline support and more, can be just the tip of the iceberg!
Further reading
Below are references I have collected during my research that you might find useful:
- https://www.patrykgalach.com/2020/04/27/unity-js-plugin/
- https://itch.io/t/140214/persistent-data-in-updatable-webgl-games
- https://itch.io/t/635029/enable-a-way-for-browser-games-to-access-the-username-of-current-player
- https://itch.io/t/1841689/unity3d-playerprefs-problem-on-updating-build
- https://github.com/giantlight-matt/unity-webgl-itch-playerprefs/
- https://discussions.unity.com/t/onpause-events-for-webgl-builds/640800/14
- https://www.heltweg.org/posts/checklist-issues-progressive-web-apps-how-to-fix/
- https://medium.com/appscope/changing-the-ios-status-bar-of-your-progressive-web-app-9fc8fbe8e6ab