In this article, we will create a Progressive Web Application! Don’t worry, we won’t make another todo list. Instead, we will build a fun game that satisfies Google’s checklist for building a PWA.
Final result
You can play it here and check the final source code on Github.
Let’s first understand what we want to achieve.
Here are the game’s functional requirements:
Our game should also satisfy the following items to be considered a PWA:
Before we jump into code, let’s grab a pen and paper and draw what our pages will look like.
To keep it simple, the home page will just have the game’s title and a button to start a new game:
The game page is basically a grid of cards and the score of the player above it:
The cards can be either in the default, flipped or matched state:
Now that we have our requirements in place, let’s kick off our application development. We can quickly bootstrap our application using preact-cli.
First, let’s install it globally:
npm i -g preact-cli
Then let’s use preact-cli to bootstrap our match-game:
preact create default match-game
The generated source code has the following structure:
└── src├── assets├── components├── index.js├── manifest.json├── routes└── style
Let’s empty both the components and routes directories since we will build our components and routes from scratch.
Our game will have 3 routes:
Let’s first create these routes:
Home route:
// routes/homeconst Home = () => (<div><h1>Home</h1><p>This is the Home route.</p></div>);export default Home;
Game route:
// routes/gameconst Game = () => (<div><h1>Game</h1><p>This is the Game route.</p></div>);export default Game;
Win route:
// routes/winconst Win = () => (<div><h1>Win</h1><p>This is the Win route.</p></div>);export default Win;
Let’s also configure our router in the App component:
import { Component } from "preact";import { Router } from "preact-router";import Home from "../routes/home";import Game from "../routes/game";import Win from "../routes/win";export default class App extends Component {render() {return (<div id="app"><Router onChange={this.handleRoute}><Home path="/" /><Game path="/game" /><Win path="/win" /></Router></div>);}}
Now that we have our routes ready, we can start building the pages.
First, let’s define some global styles in the styles/index.css file. We will use the “Press Start 2P” font, which is very common in games.
After downloading the font and placing it in assets/fonts , our final index.css should look like this:
/* src/style/index.css */@import url("https://fonts.googleapis.com/css?family=Press+Start+2P");html,body {height: 100%;width: 100%;padding: 0;margin: 0;background: #fafafa;font-family: "Press Start 2P", cursive;font-size: 12px;}* {box-sizing: border-box;}button {font-family: "Press Start 2P", cursive;color: #000;cursor: pointer;-webkit-tap-highlight-color: rgba(0, 0, 0, 0);}
The homepage is quite simple since it’s just a header and a button to start a new game.
The button redirects to the /game route. Although we can either use a Link from preact-router (which acts as an anchor) or a button, a button, in this case, is more accessible due to its native styling.
// src/routes/home/index.jsimport { Component } from "preact";import { route } from "preact-router";import style from "./style.css";export default class Home extends Component {startGame = () => {route("/game");};render() {return (<div class={style.home}><div class={style.head}><h2>Match Game</h2></div><button class={style.button} onClick={this.startGame}>New Game</button></div>);}}
/* src/routes/home/style.css */.home {position: absolute;top: 0;left: 0;width: 100%;height: 100%;display: flex;flex-direction: column;justify-content: center;align-items: center;text-align: center;line-height: 1.5;}.head {max-width: 300px;padding-bottom: 30px;font-size: 1.2rem;}.button {width: 200px;height: 50px;background-color: #e7a61a;-webkit-tap-highlight-color: rgba(0, 0, 0, 0);color: #000;border: 0;border-radius: 10px;-webkit-box-shadow: 0px 3px 6px 0px #000;-moz-box-shadow: 0px 3px 6px 0px #000;box-shadow: 0px 3px 6px 0px #000;outline: none;font-family: "Press Start 2P", cursive;font-size: 1.2rem;cursor: pointer;}
Note: we are importing the home styles from ./style.css. This is because preact-cli provides out of the box support for CSS Modules! If you don’t know anything about CSS Modules and you don’t want to learn about them now, you can still continue the tutorial without a problem. All what you have to understand is that in order to map the styles of a JSX node in index.css to a CSS declaration in style.css , we just need set its class name to the corresponding declaration name (e.g. style.home is mapped to the CSS declaration with class name home ).
The winning page is actually very similar to the home page, except that it will have the winning message instead of the game’s title.
// src/routes/win/index.jsimport { Component } from "preact";import { route } from "preact-router";import style from "./style.css";export default class Win extends Component {startGame = () => {route("/game");};render() {return (<div class={style.win}><div class={style.head}><div class={style.emoji}>🎉</div><div>You won!</div></div><button class={style.button} onClick={this.startGame}>New Game</button></div>);}}
/* src/routes/win/style.css */.win {position: absolute;top: 0;left: 0;width: 100%;height: 100%;display: flex;flex-direction: column;justify-content: center;align-items: center;text-align: center;line-height: 1.5;}.head {max-width: 300px;padding-bottom: 30px;font-size: 1.2rem;}.emoji {font-size: 6rem;}.button {width: 200px;height: 50px;background-color: #e7a61a;color: #000;border: 0;border-radius: 10px;-webkit-box-shadow: 0px 3px 6px 0px #000;-moz-box-shadow: 0px 3px 6px 0px #000;box-shadow: 0px 3px 6px 0px #000;outline: none;font-family: "Press Start 2P", cursive;font-size: 1.2rem;cursor: pointer;}
Let’s first build the card component so that we can use it in the game’s grid.
The card component has a back and a front.
// src/components/card/index.jsimport style from "./style.css";export default function Card({ hiddenValue, flipStatus, onClick }) {return (<div class={style.card} data-flipStatus={flipStatus}><button class={style.front} onClick={onClick}>?</button><div class={style.back}>{hiddenValue}</div></div>);}
/* src/components/card/style.css */.card {position: relative;transition: 0.3s;transform-style: preserve-3d;}.card .front,.card .back {position: absolute;top: 0;left: 0;width: 100%;height: 100%;display: flex;align-items: center;justify-content: center;border-radius: 6px;font-size: 3rem;backface-visibility: hidden;border: 1px solid #864601;/* box shadow */box-shadow: 0px 6px 5px 0px #864601;-webkit-box-shadow: 0px 6px 5px 0px #864601;-moz-box-shadow: 0px 6px 5px 0px #864601;}.card .front {background: #e7a61a;color: #864601;z-index: 2;transform: rotateY(0deg);}.card .back {background: white;}.card[data-flipStatus="FLIPPED"],.card[data-flipStatus="MATCHED"],.back {transform: rotateY(180deg);}.card[data-flipStatus="MATCHED"] .back {opacity: 0.2;border: 3px dashed red;}
Note: The CSS flip animation is inspired from here. The link is a really good source if you’re curious about how it works.
The final result looks like this:
Every time we start a game, we should shuffle the positions of our cards. Moreover, every card should have a unique key to identify it in the grid and an emoji value. To do so, we can create a shuffling function in the App component and pass down its resulting cards to the Game route:
// src/components/app.jsimport { Component } from "preact";import { Router } from "preact-router";import Home from "../routes/home";import Game from "../routes/game";import Win from "../routes/win";/*** helper function to generate a schuffled array of cards*/function generateGridCards() {const emojis = ["🚀", "😺", "🐶", "🏈", "📦", "🙊"];return [...emojis, ...emojis].sort(() => Math.random() - Math.random()).map((emoji, idx) => ({ key: idx, emoji }));}export default class App extends Component {render() {return (<div id="app"><Router onChange={this.handleRoute}><Home path="/" /><Game path="/game" cards={generateGridCards()} /><Win path="/win" /></Router></div>);}}
Note: the schuffling function generateGridCards by creating an array that has two duplications of the emojis array, sorting the array randomly, and then mapping each emoji to a card object containing a unique key (the index) and the emoji value.
We can represent the game’s state in a way that reflects exactly the requirements we discussed in the first section:
When the player flips a card, the following can be done:
// src/routes/game/index.jsimport { Component } from "preact";import { route } from "preact-router";import Card from "../../components/card";import style from "./style";export default class Game extends Component {state = {flippedCards: { first: {}, second: {} },isMatched: {},score: 0,};getCardFlipStatus = ({ key, emoji }) => {const { flippedCards, isMatched } = this.state;if (isMatched[emoji]) {return "MATCHED";}if ([flippedCards.first.key, flippedCards.second.key].includes(key)) {return "FLIPPED";}return "DEFAULT";};createCardClickListener = (card) => () => {this.flipCard(card);};flipCard = (card) => {const { flippedCards } = this.state;// if it's the first card to be flipped, we don't need// to worry about anything elseconst isFirstFlippedCard = Object.keys(flippedCards.first).length === 0;if (isFirstFlippedCard) {return this.setState({ flippedCards: { ...flippedCards, first: card } });}this.flipSecondCard(card);};flipSecondCard = (card) => {const { flippedCards, isMatched, score } = this.state;// Flip the second and then check after 500 ms whether it's a match// or mismatch and handle itthis.setState({ flippedCards: { ...flippedCards, second: card } });setTimeout(() => {if (flippedCards.first.emoji === card.emoji) {// it's a matchthis.setState({score: score + 1,isMatched: { ...isMatched, [card.emoji]: true },});if (score === 5) {this.handleWin();}}// it's a mismatch, so flip the cards backthis.setState({ flippedCards: { first: {}, second: {} } });}, 500);};handleWin = () => {setTimeout(() => {route("/win");}, 500);};render(props, state) {return (<div class={style.game}><header class={style.score}>Score: {state.score}</header><div class={style.grid}>{props.cards.map((card) => (<CardhiddenValue={card.emoji}flipStatus={this.getCardFlipStatus(card)}disabled={false}onClick={this.createCardClickListener(card)}/>))}</div></div>);}}
When it comes to styling the game’s grid, we have THE perfect case for CSS grid.
/* src/routes/game/style.css */.game {position: absolute;top: 0;bottom: 0;left: 0;right: 0;width: 100%;height: 100%;display: flex;flex-direction: column;justify-content: space-between;align-items: center;padding-top: 15px;max-height: 568px;}.score {text-align: center;font-size: 2rem;position: relative;top: 30px;}.body {display: flex;align-items: center;flex-direction: column;width: 100%;height: 100%;}.grid {display: grid;grid-template-columns: repeat(3, 90px);grid-template-rows: repeat(4, 90px);grid-gap: 10px;position: absolute;bottom: 10px;}
And… that’s it! The game should be complete by now! 👻
To do so, we first need to generate icons for Android and iOS. To keep things simple, we can choose our Card component to be the icon of the game.
Once we have a screenshot of the Card component, we can use a tool like this to generate the different sizes of icons and place them in the assets/icons directory.
For iOS, we need to add special link tags pointing the apple-touch-icon . In order to achieve that, we can create a template index.html in the src directory that has the required link tags. The following template is basically the default template of preact-cli with our link tags added to it:
<!-- src/index.html --><!DOCTYPE html><html lang="en"><head><meta charset="utf-8" /><title><%= htmlWebpackPlugin.options.title %></title><meta name="viewport" content="width=device-width,initial-scale=1" /><meta name="mobile-web-app-capable" content="yes" /><meta name="apple-mobile-web-app-capable" content="yes" /><linkrel="apple-touch-icon"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon.png"/><linkrel="apple-touch-icon"sizes="57x57"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-57x57.png"/><linkrel="apple-touch-icon"sizes="72x72"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-72x72.png"/><linkrel="apple-touch-icon"sizes="76x76"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-76x76.png"/><linkrel="apple-touch-icon"sizes="114x114"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-114x114.png"/><linkrel="apple-touch-icon"sizes="120x120"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-120x120.png"/><linkrel="apple-touch-icon"sizes="144x144"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-144x144.png"/><linkrel="apple-touch-icon"sizes="152x152"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-152x152.png"/><linkrel="apple-touch-icon"sizes="180x180"href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-180x180.png"/><linkrel="manifest"href="<%= htmlWebpackPlugin.files.publicPath %>manifest.json"/><% if (htmlWebpackPlugin.options.manifest.theme_color) { %><metaname="theme-color"content="<%= htmlWebpackPlugin.options.manifest.theme_color %>"/><% } %> <% for (var chunk of webpack.chunks) { %> <% if (chunk.names.length=== 1 && chunk.names[0] === 'polyfills') continue; %> <% for (var file ofchunk.files) { %> <% if (htmlWebpackPlugin.options.preload &&file.match(/\.(js|css)$/)) { %><linkrel="preload"href="<%= htmlWebpackPlugin.files.publicPath + file %>"as="<%= file.match(/\.css$/)?'style':'script' %>"/><% } else if (file.match(/manifest\.json$/)) { %><linkrel="manifest"href="<%= htmlWebpackPlugin.files.publicPath + file %>"/><% } %> <% } %> <% } %></head><body><%= htmlWebpackPlugin.options.ssr({ url: '/' }) %><scriptdefersrc="<%= htmlWebpackPlugin.files.chunks['bundle'].entry %>"></script><script>window.fetch ||document.write('<script src="<%= htmlWebpackPlugin.files.chunks["polyfills"].entry %>"><\/script>');</script></body></html>
We can then modify our production NPM scripts in order to use the template:
"scripts": {"build": "preact build --template src/index.html","serve": "npm run build && preact serve"}
We also need to modify the src/manifest.json file in order to reflect our Android icons and game’s title:
{"name": "Match Game","short_name": "Match Game","start_url": "/","display": "standalone","orientation": "portrait","background_color": "#fff","theme_color": "#fff","icons": [{"src": "/assets/icons/android-chrome-192x192.png","type": "image/png","sizes": "192x192"},{"src": "/assets/icons/android-chrome-256x256.png","type": "image/png","sizes": "512x512"}]}
It’s time to evaluate our game based on the requirements we set at the beginning.
To build the game, we can use the build NPM script:
npm run build
The game‘s build result should be available in the /build directory. We can then easily deploy and host the build directory somewhere like ▲now.
Let’s then finally take a look at our game’s functional requirements and PWA checklist and see what we have achieved:
Running Google’s Lighthouse tool gives the following: