Phaser is a fantastic HTML5 game framework, which can easily be run on any device. The downside is since it is an HTML5 framework, there is no out-of-the-box solution to create a mobile app version of your game. You can use other tools like PhoneGap, CocoonJS, Ionic, etc. to create a hybrid mobile app, but you are dependent on third party tools. However, if you optimize your game properly, anyone can play your game on a mobile device, but it requires the user to visit your site, and it doesn’t have some of the nice app features, like an icon on your home screen, or playing the game when you’re offline.

What if you could have a hybrid app that is a mixture of a native mobile app and a web app? Progressive Web Apps, or PWAs, feel like a native mobile app, can work offline, are responsive, and are very easy to install. Additionally, we can make our game a PWA without any third party tools. We just need to add a few files to our project, and we need to add some additional code to our main html page.

You can download all of the files associated with the source code here .

Don't miss out! Offer ends in Access all 200+ courses

Access all 200+ courses New courses added monthly

New courses added monthly Cancel anytime

Cancel anytime Certificates of completion ACCESS NOW

Tutorial Requirements

For this tutorial, you will need the following:

Basic to intermediate JavaScript skills

A code editor

A local web server

Chrome Web Browser

For this tutorial, it is recommended that you are familiar with basic Phaser concepts such as scenes, setting up the config for your game, running your game locally, etc. If you are not familiar with these concepts, you will be able to follow along with the tutorial, but we will not be covering these topics in depth. If you would like to learn more about these concepts or would like a refresher, you can check out the How to Create a Game with Phaser 3 tutorial here on GameDev Academy.

Project Setup

For this tutorial, we are going to reuse some of the code from the Phaser 3 webpack project template that is available on GitHub. We won’t be using webpack for this tutorial, but we will be using the base template and Phaser logo image that is in the repo, along with a few additional images and a style sheet. You can download the base project code here: Project Template

In the zip folder, you will see three folders (css, js, and img) and an index.html file. If you open index.html in your code editor, you should see the following code:

<!DOCTYPE html> <html> <head> <link rel="stylesheet" href="css/style.css" /> </head> <body> <script src="https://cdn.jsdelivr.net/gh/photonstorm/phaser@3.10.1/dist/phaser.min.js"></script> <script src="js/game.js"></script> </body> </html> 1 2 3 4 5 6 7 8 9 10 < ! DOCTYPE html > < html > < head > < link rel = "stylesheet" href = "css/style.css" / > < / head > < body > <script src = "https://cdn.jsdelivr.net/gh/photonstorm/phaser@3.10.1/dist/phaser.min.js" > </script> <script src = "js/game.js" > </script> < / body > < / html >

In the js folder, there is a file called game.js which has all of the logic for our Phaser game. If you open, game.js you will see the following code:

var config = { type: Phaser.AUTO, parent: 'phaser-example', width: window.innerWidth, height: window.innerHeight, scene: { preload: preload, create: create } }; var game = new Phaser.Game(config); function preload() { this.load.image('logo', 'img/logo.png'); } function create() { this.logo = this.add.image(0, 0, 'logo'); this.logo.setScale(0.5); Phaser.Display.Align.In.Center( this.logo, this.add.zone(window.innerWidth/2, window.innerHeight/2, window.innerWidth, window.innerHeight) ); this.tweens.add({ targets: this.logo, y: 450, duration: 2000, ease: 'Power2', yoyo: true, loop: -1 }); } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 var config = { type : Phaser . AUTO , parent : 'phaser-example' , width : window . innerWidth , height : window . innerHeight , scene : { preload : preload , create : create } } ; var game = new Phaser . Game ( config ) ; function preload ( ) { this . load . image ( 'logo' , 'img/logo.png' ) ; } function create ( ) { this . logo = this . add . image ( 0 , 0 , 'logo' ) ; this . logo . setScale ( 0.5 ) ; Phaser . Display . Align . In . Center ( this . logo , this . add . zone ( window . innerWidth / 2 , window . innerHeight / 2 , window . innerWidth , window . innerHeight ) ) ; this . tweens . add ( { targets : this . logo , y : 450 , duration : 2000 , ease : 'Power2' , yoyo : true , loop : - 1 } ) ; }

Lastly, if you start your server and try running your game, you should see a black screen with the Phaser logo.

Adding a service worker

Now that our project is set up, we now work on adding a service worker to our game. Before we start coding, let’s review what a service worker is. A service worker is a JavaScript worker or web worker that is essentially a JavaScript file that does not run in the main browsers thread and it can be used to intercept requests, cache and retrieve cached resources, and it can deliver push notifications. What does this mean for us? We can use the service worker to cache our game assets on the user’s browser, which will allow us to use the cached assets for faster loading times and we can use them to allow the player to play our game offline.

In order to use service workers, your game must be hosted over HTTPS (you can use localhost when testing). Lastly, most browsers support service workers, but not all of them do. You can track which browsers are supported here: Service Worker Ready.

With that out of the way, let’s start adding our service worker. To install a service worker, the first thing we need to do is register our service worker. In your project folder create a new file called sw.js and place this at the root of your project. For now, we will leave this file empty. Next, open index.html and add the following code above the other JavaScript files:

<script> if ('serviceWorker' in navigator) { window.addEventListener('load', function() { navigator.serviceWorker.register('/sw.js', {scope: '/'}).then(function(registration) { console.log('ServiceWorker registration successful with scope: ', registration.scope); }, function(err) { console.log('ServiceWorker registration failed: ', err); }); }); } </script> 1 2 3 4 5 6 7 8 9 10 11 <script> if ( 'serviceWorker' in navigator ) { window . addEventListener ( 'load' , function ( ) { navigator . serviceWorker . register ( '/sw.js' , { scope : '/' } ) . then ( function ( registration ) { console . log ( 'ServiceWorker registration successful with scope: ' , registration . scope ) ; } , function ( err ) { console . log ( 'ServiceWorker registration failed: ' , err ) ; } ) ; } ) ; } </script>

Let’s review the code we just added:

First, we check if the service worker API is available.

If the API is available, then once the page has loaded we register the service worker at /sw.js and we set the scope for our service worker to be / . The scope parameter is used to control which parts of your application can be used by the service worker. For example, if you set the scope to be /games/ then the service worker will only be able to access files under the /games/ path.

and we set the scope for our service worker to be . The scope parameter is used to control which parts of your application can be used by the service worker. For example, if you set the scope to be then the service worker will only be able to access files under the path. Lastly, we log some information about the service worker.

Now, if you save and reload your game in the browser if you open the console inside Chrome’s developer tools, you should see a message about the service worker registration being successful.

You can also validate that the service worker was installed by clicking on the Application tab in Chrome’s developer tools. From there, if you click on the Service Workers tab you will see the service worker we registered.

Cacheing assets

Even though our service worker is installed, it is not doing anything. To fix this, we will update our service worker to cache all of the assets that are used by our game. In sw.js , add the following code:

var cacheName = 'phaser-v1'; var filesToCache = [ '/', '/index.html', '/img/logo.png', '/img/icon-192.png', '/img/icon-256.png', '/img/icon-512.png', '/js/game.js', '/css/style.css', 'https://cdn.jsdelivr.net/gh/photonstorm/phaser@3.10.1/dist/phaser.min.js' ]; self.addEventListener('install', function(event) { console.log('sw install'); event.waitUntil( caches.open(cacheName).then(function(cache) { console.log('sw caching files'); return cache.addAll(filesToCache); }).catch(function(err) { console.log(err); }) ); }); 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 var cacheName = 'phaser-v1' ; var filesToCache = [ '/' , '/index.html' , '/img/logo.png' , '/img/icon-192.png' , '/img/icon-256.png' , '/img/icon-512.png' , '/js/game.js' , '/css/style.css' , 'https://cdn.jsdelivr.net/gh/photonstorm/phaser@3.10.1/dist/phaser.min.js' ] ; self . addEventListener ( 'install' , function ( event ) { console . log ( 'sw install' ) ; event . waitUntil ( caches . open ( cacheName ) . then ( function ( cache ) { console . log ( 'sw caching files' ) ; return cache . addAll ( filesToCache ) ; } ) . catch ( function ( err ) { console . log ( err ) ; } ) ) ; } ) ;

Let’s review the code we just added:

First, we declared two variables cacheName and filesToCache . The cacheName variable is used to store the name of the cache that we will be using to store the cached version of our files. The filesToCache variable is an array of files that we want to cache for our game.

and . The variable is used to store the name of the cache that we will be using to store the cached version of our files. The variable is an array of files that we want to cache for our game. Next, we added an event listener for the service worker install event and we provided a callback function that will run when the install event is triggered.

In the callback function, we called the event.waitUntil() method, which takes a promise as an argument and it uses it to know if the installation was successful.

method, which takes a promise as an argument and it uses it to know if the installation was successful. In the event.waitUntil method, we first call the caches.open() method, which is used to open the cache in the user’s browser. This method takes the name of the cache you want to open, cacheName , and it returns a promise that will resolve to the cache object that is stored in the user’s browser.

method, we first call the method, which is used to open the cache in the user’s browser. This method takes the name of the cache you want to open, , and it returns a promise that will resolve to the cache object that is stored in the user’s browser. Finally, we call the addAll() method on the cache object that was returned. This method takes the array of URLs that we want to be cached, filesToCache , and it returns a promise that will resolve with void if all of the files are cached. One important thing to note is if any of the files are fail to download into the cache, then the whole install step will fail.

This may be a lot to digest, but basically, in the install callback, we did the following:

We opened the cache.

We cached our list of files.

We confirmed if the files were cached or not.

With our logic for caching in place, we need to add the logic that will allow us to use the cached assets. To use the cached assets, we need to add a new event listener for the fetch event, which is triggered any time a user visits a page after the service worker has been installed. In sw.js add the following code at the bottom of the file:

self.addEventListener('fetch', (event) => { console.log('sw fetch'); console.log(event.request.url); event.respondWith( caches.match(event.request).then(function(response) { return response || fetch(event.request); }).catch(function (error) { console.log(error); }) ); }); 1 2 3 4 5 6 7 8 9 10 11 self . addEventListener ( 'fetch' , ( event ) = > { console . log ( 'sw fetch' ) ; console . log ( event . request . url ) ; event . respondWith ( caches . match ( event . request ) . then ( function ( response ) { return response | | fetch ( event . request ) ; } ) . catch ( function ( error ) { console . log ( error ) ; } ) ) ; } ) ;

In the code above we are doing the following:

We added an event listener for the fetch event for our service worker. This event is triggered anytime a request is made that is within the scope of our service worker.

event for our service worker. This event is triggered anytime a request is made that is within the scope of our service worker. When we receive the event we called the event.respondWith() method. This method with prevent the browser’s default and will use the promise we provide instead.

method. This method with prevent the browser’s default and will use the promise we provide instead. In the event.respondWith() method, we first called the caches.match() method and we passed it the event.request object. Then, if the requested resource was found in the cache we return the cached resource. If the requested resource was not found in the cache, we fetch that request and return the response.

Testing in Chrome

Now that we have the code for loading our cached assets in place, we can test our game offline to make sure the cached assets are being loaded properly. If you save and reload your game, it will probably look like nothing has changed, and if you look in Console in the developer tools, you might not see any logs for the fetch event. The reason for this is because of how the service worker is actually updated.

When the browser detects a change in the service worker file, it will install the new service worker in the background. When this happens, you old service worker is still controlling the current page you are on, and it will enter a waiting state. Once you close the current page, or navigate to a different site, then the old service worker will be destroyed and the new one will take control.

To see this in Chrome, if you switch back to the Application tab, and click on Service Workers , you should see the currently active service worker, and the updated one.



No, if you close the tab your game is running in, and you reload your game in another tab the service worker should show it has been updated.

To get around this, you can check the Update on reload checkbox at the top of the service workers tab. When this is checked, Chrome will automatically install the updated service worker for us.

Now, if you look in the Console tab, you should see some fetch events being logged.

To see if our game will work when we are offline, we can either shut down the server that is rendering our game, or back in the service workers tab, we can click the Offline checkbox. Now, if you try reloading your game, you should see that the service worker is loading the assets for our game from the cache, and that our game is playable offline.

Cache management

If you ever want to force the service worker to use a new version of the cache, or if decide to use multiple caches, you will need a way to clean up the old caches on the users device. To do this, we can add an event listener for the activate event. This event is triggered any time a service worker takes control, and we will use this event to clean up our cache.

In sw.js , add the following code at the bottom of the file:

self.addEventListener('activate', function(event) { console.log('sw activate'); event.waitUntil( caches.keys().then(function(keyList) { return Promise.all(keyList.map(function(key) { if (key !== cacheName) { console.log('sw removing old cache', key); return caches.delete(key); } })); }) ); }); 1 2 3 4 5 6 7 8 9 10 11 12 13 self . addEventListener ( 'activate' , function ( event ) { console . log ( 'sw activate' ) ; event . waitUntil ( caches . keys ( ) . then ( function ( keyList ) { return Promise . all ( keyList . map ( function ( key ) { if ( key ! == cacheName ) { console . log ( 'sw removing old cache' , key ) ; return caches . delete ( key ) ; } } ) ) ; } ) ) ; } ) ;

Let’s review the code we just added:

First, we added an event listener for the activate event, and we passed it a callback function that will run when this event is triggered.

event, and we passed it a callback function that will run when this event is triggered. In this callback function, we use the caches.key() method to get all of the current caches for our service worker.

method to get all of the current caches for our service worker. We then loop through all of these keys and we delete them if they are not equal to our current cacheName variable.

Adding to our home screen

With our game now working offline, we will start adding the functionality for allowing our game to be added to the users home screen. To include this functionality, we will need to listen to the beforeinstallprompt event that Chrome will fire when certain conditions are meet. When this event is fired, we can show a button to have the user install our game, and when they click this button, it will show Chrome’s prompt to add the app to their home screen.



Chrome used to automatically show the prompt to add the PWA to the user’s home screen, however starting in Chrome 68, Chrome no longer does this automatically and you have to listen for the beforeinstallprompt .

In order for Chrome to fire the beforeinstallprompt your PWA will need to meet the following criteria:

The web app is not already installed.

The user must have been on the site for 30 seconds.

The web app has a manifest file that includes the following: short_name or name icons must include 192px and 512px sized icons start_url display must be one of: fullscreen , standalone , minimal-ui .

Has to be served over HTTPS.

Has a registered service worker that has a fetch event handler.

Let’s start adding the code for this to our game. The first thing we will do is create the manifest file. The web app manifest file is a simple JSON file that tells the browser about the your web application and how it should behave when it is installed on the user’s device. In your project, create a new file called manifest.json , and add the following code to it:

{ "name": "Phaser PWA", "short_name": "Phaser", "description": "Phaser template Web App.", "start_url": "/", "background_color": "#000000", "theme_color": "#0f4a73", "display": "standalone", "icons": [{ "src": "img/icon-192.png", "sizes": "192x192", "type": "image/png" },{ "src": "img/icon-256.png", "sizes": "256x256", "type": "image/png" },{ "src": "img/icon-512.png", "sizes": "512x512", "type": "image/png" }] } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 { "name" : "Phaser PWA" , "short_name" : "Phaser" , "description" : "Phaser template Web App." , "start_url" : "/" , "background_color" : "#000000" , "theme_color" : "#0f4a73" , "display" : "standalone" , "icons" : [ { "src" : "img/icon-192.png" , "sizes" : "192x192" , "type" : "image/png" } , { "src" : "img/icon-256.png" , "sizes" : "256x256" , "type" : "image/png" } , { "src" : "img/icon-512.png" , "sizes" : "512x512" , "type" : "image/png" } ] }

Let’s review the code we just added:

The name and short_name properties are the names that are shown to the user on their home screen and in the prompt that is shown to the user when they install the app.

and properties are the names that are shown to the user on their home screen and in the prompt that is shown to the user when they install the app. The icons property is an array of icons that will be used for the app icon in the home screen and the app launcher.

property is an array of icons that will be used for the app icon in the home screen and the app launcher. The start_url tells the browser where the web app should start when it is launched.

tells the browser where the web app should start when it is launched. The display property is used to customize the browser UI that is shown when the app is launched. You can read more about the different options here.

property is used to customize the browser UI that is shown when the app is launched. You can read more about the different options here. The theme_color property is used to control the color of the task bar.

property is used to control the color of the task bar. The background property is the color that is used on the web app splash screen.

With the code for the manifest file in place, we just need to update our index.html file. Open index.html and replace all of the code in the file with the following code:

<!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta charset="utf-8"> <meta name="theme-color" content="black" /> <link rel="manifest" href="manifest.json" /> <link rel="icon" href="img/icon-192.png" sizes="192x192" /> <link rel="icon" href="img/icon-256.png" sizes="256x256" /> <link rel="icon" href="img/icon-512.png" sizes="512x512" /> <link rel="stylesheet" href="css/style.css" /> </head> <body> <!-- The Modal --> <div id="myModal" class="modal"> <!-- Modal content --> <div class="modal-content"> <span class="close">×</span> <p>Add to home screen?</p> <button onclick="offlinePrompt()">Install</button> </div> </div> <script> if ('serviceWorker' in navigator) { window.addEventListener('load', function() { navigator.serviceWorker.register('/sw.js', {scope: '/'}).then(function(registration) { console.log('ServiceWorker registration successful with scope: ', registration.scope); }, function(err) { console.log('ServiceWorker registration failed: ', err); }); }); } let deferredPrompt; window.addEventListener('beforeinstallprompt', function (e) { console.log('beforeinstallprompt triggered'); e.preventDefault(); deferredPrompt = e; modal.style.display = 'block'; }); // Get the modal var modal = document.getElementById('myModal'); // Get the <span> element that closes the modal var span = document.getElementsByClassName('close')[0]; // When the user clicks anywhere outside of the modal, close it window.onclick = function(event) { if (event.target == modal) { modal.style.display = 'none'; } } // When the user clicks on <span> (x), close the modal span.onclick = function() { modal.style.display = 'none'; } function offlinePrompt() { deferredPrompt.prompt(); } </script> <script src="https://cdn.jsdelivr.net/gh/photonstorm/phaser@3.10.1/dist/phaser.min.js"></script> <script src="js/game.js"></script> </body> </html> 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 < ! DOCTYPE html > < html > < head > < meta name = "viewport" content = "width=device-width, initial-scale=1" > < meta charset = "utf-8" > < meta name = "theme-color" content = "black" / > < link rel = "manifest" href = "manifest.json" / > < link rel = "icon" href = "img/icon-192.png" sizes = "192x192" / > < link rel = "icon" href = "img/icon-256.png" sizes = "256x256" / > < link rel = "icon" href = "img/icon-512.png" sizes = "512x512" / > < link rel = "stylesheet" href = "css/style.css" / > < / head > < body > < ! -- The Modal -- > < div id = "myModal" class = "modal" > < ! -- Modal content -- > < div class = "modal-content" > < span class = "close" > × < / span > < p > Add to home screen ? < / p > < button onclick = "offlinePrompt()" > Install < / button > < / div > < / div > <script> if ( 'serviceWorker' in navigator ) { window . addEventListener ( 'load' , function ( ) { navigator . serviceWorker . register ( '/sw.js' , { scope : '/' } ) . then ( function ( registration ) { console . log ( 'ServiceWorker registration successful with scope: ' , registration . scope ) ; } , function ( err ) { console . log ( 'ServiceWorker registration failed: ' , err ) ; } ) ; } ) ; } let deferredPrompt ; window . addEventListener ( 'beforeinstallprompt' , function ( e ) { console . log ( 'beforeinstallprompt triggered' ) ; e . preventDefault ( ) ; deferredPrompt = e ; modal . style . display = 'block' ; } ) ; // Get the modal var modal = document . getElementById ( 'myModal' ) ; // Get the <span> element that closes the modal var span = document . getElementsByClassName ( 'close' ) [ 0 ] ; // When the user clicks anywhere outside of the modal, close it window . onclick = function ( event ) { if ( event . target == modal ) { modal . style . display = 'none' ; } } // When the user clicks on <span> (x), close the modal span . onclick = function ( ) { modal . style . display = 'none' ; } function offlinePrompt ( ) { deferredPrompt . prompt ( ) ; } </script> <script src = "https://cdn.jsdelivr.net/gh/photonstorm/phaser@3.10.1/dist/phaser.min.js" > </script> <script src = "js/game.js" > </script> < / body > < / html >

In the code above we did the following:

In the <head> section of our code, we added a meta tag to make our game responsive. We also added a link to our manifest file, and we added links to our icons.

section of our code, we added a meta tag to make our game responsive. We also added a link to our manifest file, and we added links to our icons. In the <body> section of our code, we added code for a modal in our game. This modal contains an install button, and when it is clicked it will show Chrome’s prompt to install the PWA. By default, we hide our modal and we only show it when the beforeinstallprompt is fired.

section of our code, we added code for a modal in our game. This modal contains an install button, and when it is clicked it will show Chrome’s prompt to install the PWA. By default, we hide our modal and we only show it when the is fired. Lastly, in the <script> section of our code, we added an event listener for the beforeinstallprompt event, and in the callback function we prevent the default event from happening, we save that event for accessing later, and lastly we display our modal. When the install button in the modal is clicked, we call the prompt() method on that event.

Now, if you save and reload your game, you can test the add to home screen functionality.

When you are developing your PWA, you can manually trigger the beforeinstallprompt event. To do this, you will need to enable the #enable-desktop-pwas flag in Chrome, and you will need to be on Chrome OS 67 or later. To enable this flag, you can visit chrome://flags/#enable-desktop-pwas and then change the dropdown to Enabled .



Once this is done, in the Application tab of the developer tools, if you click on the Manifest tab, you should see a Add to homescreen link, which when clicked it will manually trigger the beforeinstallprompt event.



With the code for the manifest file in place, our PWA is complete. You now have an offline first Phaser game that is a PWA.

Conclusion

I hoped you enjoyed this tutorial and found it helpful. If you have any questions, or suggestions on what we should cover next, let us know in the comments below.