Build and Monetize a Progressive Web App Game Using Vite and Vanilla JS

Last Updated On 13 Jun 2023 by

In this tutorial, you’ll read about the main steps to build your own WPA HTML5 game. The game is deployed at close to no cost using Github pages and uses a simple monetizing strategy using Adsense.

Play Bricks Here!

The game we are building is Bricks. It’s a shape stacking game similar to the old classic Tetris but on a squared grid and there without gravity, with… out… stress…

An image

Try it now in your browser!

The Setup

First thing we need to do is setup our project main structure and build system. This will help us in our dev process and later during deployment. For that we use Vite, the next generation frontend tooling. So while this project will use Vanilla JS without a framework, we still want frontend tooling to help us bundle the app and its assets.

In a few steps we bootstrap a new Vite Vanilla JS project:

npm create vite@latest

Follow the instructions and that’s it, let’s start cooking!

Progressive Web App you say?

A PWA allows you to host a Mobile/Desktop app just like you would host a static website, without having to go through the App store publication process and remain fully self published. The conditions to qualify as PWA are more detailed in such article but in short you need to have:

  • an app icon,
  • host your site on https,
  • register a service worker
  • include a web app manifest file

First thing we need is to create a manifest.webmanifest file. In this tutorial we use a Vite plugin to simplify the PWA registration process so instead of adding the file, we add it top oue Vite config file vite.config.file as below:

...

export default ({ mode }) => {
    return defineConfig({
      plugins: [
        VitePWA({
          registerType: 'autoUpdate',
          manifest: {

            "start_url": "/index.html", 
          
            "background_color": "#7cc4ff",
            "theme_color": "#7cc4ff",
            "description": "Fit as many piece as you can",
            "display": "standalone",
            "icons": [
              {
                "src": "icon.png",
                "sizes": "512x512",
                "type": "image/png",
                "purpose": "any maskable"
              }
            ],
            "name": "Bricks",
            "short_name": "Bricks",
          }
        }),

       ...
    });
}

See the entire file here

The next step is to add the JS code to register the service worker:

import { registerSW } from "virtual:pwa-register";

if ("serviceWorker" in navigator && !/localhost/.test(window.location)) {
  registerSW();
}

Ok so we got a PWA now but we’re still missing the install button!

// Code to handle install prompt on desktop

let deferredPrompt;
const addBtn = document.querySelector('#install-btn');

window.addEventListener('beforeinstallprompt', (e) => {
  // Prevent Chrome 67 and earlier from automatically showing the prompt
  e.preventDefault();
  // Stash the event so it can be triggered later.
  deferredPrompt = e;
  // Update UI to notify the user they can add to home screen
  addBtn.style.display = 'block';

  addBtn.addEventListener('click', () => {
    // hide our user interface that shows our A2HS button
    addBtn.style.display = 'none';
    // Show the prompt
    deferredPrompt.prompt();
    // Wait for the user to respond to the prompt
    deferredPrompt.userChoice.then((choiceResult) => {
      if (choiceResult.outcome === 'accepted') {
        console.log('User accepted the A2HS prompt');
      } else {
        console.log('User dismissed the A2HS prompt');
      }
      deferredPrompt = null;
    });
  });
});

See the entire file here

The code registers a service worker and enable a app install button. This feature seems to only reliably work on Chrome Desktop and Android and will create an app Icon on your main screen. The user can then access the app directly without opening their browser.

See the entire file here

Let’s make a game

So now we have a basic PWA setup, we can start building our game.

One Stop Store

While I tried to keep it simple and Vanilla, without too many dependencies, I still wanted to have a global state manager. The following is a bare bone Object implementation that will manage the state of our game.

Define a Single State

Heavily inspired by VueX and Flux architecture we define a single state as below:

const state = {
  score: 0,
  bankPieces: [
    new Piece(Piece.PIECE_NONE.type),
    new Piece(Piece.PIECE_NONE.type),
    new Piece(Piece.PIECE_NONE.type)
  ],
  backupPiece: new Piece(Piece.PIECE_NONE.type),
  grid: new Grid(),
  changes: {
    grid: {
      previous: 0,
      value: 1,
    },
    bank: {
      previous: 0,
      value: 1,
    }
  }
};

Define Mutations

Mutations are function that mutate the state. they are the only allowed ways of mutating our state. This way logic remain centralized and easy to understand and maintain.

mutate: {
    resetBank () {
      state.bankPieces.forEach(piece => piece.shuffle())
      state.changes.bank.value += 1;
    },
    undo () {
      ...
    },
    restart () {
      ...
    },
    hasRendered (target) {
      state.changes[target].previous = state.changes[target].value;
    },
    bumpScoreBy (points) {
      state.score += points;
    },
    setBackupPiece (piece) {
      state.changes.bank.value += 1;
      state.backupPiece.copyPiece(piece);
    },
    removeFromBank (piece) {
      ...
    },
    checkWins () {
      ...
    },
  },

Define Getters

Finally we expose a few getters so that the view layer can easily consume the state and render it without extra logical efforts

getters: {
    getPieceById: pieceId => {
      switch (pieceId) {
        case 'bank-piece-1':
          return state.bankPieces[0];
        case 'bank-piece-2':
          return state.bankPieces[1];
        case 'bank-piece-3':
          return state.bankPieces[2];
        case 'backup-piece':
          return state.backupPiece;
      }
    },
    canAcceptBackup: () => state.backupPiece.type === Piece.PIECE_NONE.type,
    gridChanged: () => state.changes.grid.previous !== state.changes.grid.value,
    bankChanged: () => state.changes.bank.previous !== state.changes.bank.value,
  }

See the entire file here

Game Objects

Our game needs a the following objects:

A cell is the most basic primitive in the game. It can be set or unset.

This class is a collection of cells to form a grid. It controls the logic of the grid, helping retrieving the state of a cell, and setting the state ready for the rendering.

A piece is a collection of cells, with an array of points an a type name to be easily recognized, rotated and shuffled.

This class manages the drag and drop interactions. keeping track of the mouse/touch position to handle dragging pieces to the grid.

Putting it together

With all this earlier work breaking down the game complexity to a global state and several helper objects, we can finally put our game together. At this point it should almost feel like a Lego toy…

In our case main.js will do the job. this is where we initialize all the objects and hook our rendering function, re-rendering the UI only when the store tells us to:

function update() {
  window.requestAnimationFrame(update);

  if (getters.gridChanged()) {
    redrawGrid();
    redrawScore();
  }
  if (getters.bankChanged()) {
    redrawBank();
  }
}

Thanks for the global state management, we can check if the state has changed and render the UI on every new frame, only as needed.

See the entire file here

Monetizing with AdSense

Assuming that you already have an account setup and approved, you just need to inject the ad snippets at the right place. For that we use the Vite HTML plugin so we can render our Ad code only on production. We add the following to our Vite config:

import { createHtmlPlugin } from 'vite-plugin-html'

const htmlPlugin = mode => {
  let banner = '';
  if (mode === 'production') {
    banner = `
      
  <ins class="adsbygoogle"
    style="display:block"
    data-ad-client="ca-pub-XXXXXXXXXX"
    data-ad-slot="XXXXXXXX"
    data-ad-format="auto"
    data-full-width-responsive="true"></ins>
  <script>
    (adsbygoogle = window.adsbygoogle || []).push({});
  </script>`;

} …

Next we can add the banner alias we just created to our HTML template:

<%- banner -%>

See index.html and vite.config.js for reference.

Wrapping Up

Of course this tutorial only skims the surface and attempts to summarize the main steps. You can find the full code on github and try the demo here

As time goes and allows, I will try to expand this tutorial into more detailed sections. If you have any questions, feel free to reach out! Hope this helps! Cheers!

Play Bricks Here!

About The Author

Headshot of Michael Iriarte aka Mika

Hi, I'm Michael aka Mika. I'm a software engineer with years of experience in frontend development. Thank you for visiting tips4devs.com I hope you learned something fun today! You can follow me on Twitter, see some of my work on GitHub, or read more about me on my website.