30th September 2018 • 12 min read
How to Create a PWA Game using Preact in 5 steps (Tutorial)

Seif Ghezala
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:
There is a 4 x 3 (4 rows, 3 columns) grid of cards.
By default, each card shows its back and hides a certain emoji.
There are in total 6 unique emojis. Each emoji is duplicated, making the total number of emojis 12.
The player can flip any card that has not been matched yet, showing the emoji behind it.
If the player flips their second card and it does not match the previously flipped one (i.e. they don’t have the same emoji), then both cards are flipped back to their initial position.
If the player flips their second card and it matches the previously flipped one, then both cards are matched and the player’s score is incremented.
The maximum score is 6 (1 point per matched pair). Once the player reaches the maximum score, the game ends with a displayed winning message.
Our game should also satisfy the following items to be considered a PWA:
The game is served over HTTPS.
All pages are responsive on tablets and mobile devices.
The game can be added to the home screen of the user’s device and look like a native app.
Offline Loading: The game should be functional offline
Fast first load.
Page transitions don’t feel like they block on the network.
Each page has a URL.
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:

Homepage
The game page is basically a grid of cards and the score of the player above it:

Game Page
The cards can be either in the default, flipped or matched state:

Default

Flipped

Matched
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:
1npm i -g preact-cliThen let’s use preact-cli to bootstrap our match-game:
1preact create default match-gameThe generated source code has the following structure:
1 └── src2 ├── assets3 ├── components4 ├── index.js5 ├── manifest.json6 ├── routes7 └── style
assets will contain our assets (favicon and other icons)
components will have our preact components including the main App component
index.js is the entry point to our application
manifetst.json : will provide information about the icons of our game in Android devices.
routes will contain our routes.
style will have global CSS for the application.
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:
/ Home route: points to the homepage containing the game’s title and a button to start playing.
/game Game route: points to the game page containing the score and the grid of cards to be flipped.
/win Win route: points to the winning page showing a message to congratulate the user after winning.
Let’s first create these routes:
Home route:
1// routes/home23const Home = () => (4 <div>5 <h1>Home</h1>6 <p>This is the Home route.</p>7 </div>8);910export default Home;
Game route:
1// routes/game23const Game = () => (4 <div>5 <h1>Game</h1>6 <p>This is the Game route.</p>7 </div>8);910export default Game;
Win route:
1// routes/win23const Win = () => (4 <div>5 <h1>Win</h1>6 <p>This is the Win route.</p>7 </div>8);910export default Win;
Let’s also configure our router in the App component:
1import { Component } from "preact";2import { Router } from "preact-router";34import Home from "../routes/home";5import Game from "../routes/game";6import Win from "../routes/win";78export default class App extends Component {9 render() {10 return (11 <div id="app">12 <Router onChange={this.handleRoute}>13 <Home path="/" />14 <Game path="/game" />15 <Win path="/win" />16 </Router>17 </div>18 );19 }20}
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:
1/* src/style/index.css */23@import url("https://fonts.googleapis.com/css?family=Press+Start+2P");45html,6body {7 height: 100%;8 width: 100%;9 padding: 0;10 margin: 0;11 background: #fafafa;12 font-family: "Press Start 2P", cursive;13 font-size: 12px;14}1516* {17 box-sizing: border-box;18}1920button {21 font-family: "Press Start 2P", cursive;22 color: #000;23 cursor: pointer;24 -webkit-tap-highlight-color: rgba(0, 0, 0, 0);25}
The homepage
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.
1// src/routes/home/index.js23import { Component } from "preact";4import { route } from "preact-router";5import style from "./style.css";67export default class Home extends Component {8 startGame = () => {9 route("/game");10 };1112 render() {13 return (14 <div class={style.home}>15 <div class={style.head}>16 <h2>Match Game</h2>17 </div>18 <button class={style.button} onClick={this.startGame}>19 New Game20 </button>21 </div>22 );23 }24}25
1/* src/routes/home/style.css */23.home {4 position: absolute;5 top: 0;6 left: 0;7 width: 100%;8 height: 100%;9 display: flex;10 flex-direction: column;11 justify-content: center;12 align-items: center;13 text-align: center;14 line-height: 1.5;15}1617.head {18 max-width: 300px;19 padding-bottom: 30px;20 font-size: 1.2rem;21}2223.button {24 width: 200px;25 height: 50px;26 background-color: #e7a61a;27 -webkit-tap-highlight-color: rgba(0, 0, 0, 0);28 color: #000;29 border: 0;30 border-radius: 10px;31 -webkit-box-shadow: 0px 3px 6px 0px #000;32 -moz-box-shadow: 0px 3px 6px 0px #000;33 box-shadow: 0px 3px 6px 0px #000;34 outline: none;35 font-family: "Press Start 2P", cursive;36 font-size: 1.2rem;37 cursor: pointer;38}
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 ).

Homepage
The winning page
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.
1// src/routes/win/index.js23import { Component } from "preact";4import { route } from "preact-router";5import style from "./style.css";67export default class Win extends Component {8 startGame = () => {9 route("/game");10 };1112 render() {13 return (14 <div class={style.win}>15 <div class={style.head}>16 <div class={style.emoji}>🎉</div>17 <div>You won!</div>18 </div>19 <button class={style.button} onClick={this.startGame}>20 New Game21 </button>22 </div>23 );24 }25}
1/* src/routes/win/style.css */23.win {4 position: absolute;5 top: 0;6 left: 0;7 width: 100%;8 height: 100%;9 display: flex;10 flex-direction: column;11 justify-content: center;12 align-items: center;13 text-align: center;14 line-height: 1.5;15}1617.head {18 max-width: 300px;19 padding-bottom: 30px;20 font-size: 1.2rem;21}2223.emoji {24 font-size: 6rem;25}2627.button {28 width: 200px;29 height: 50px;30 background-color: #e7a61a;31 color: #000;32 border: 0;33 border-radius: 10px;34 -webkit-box-shadow: 0px 3px 6px 0px #000;35 -moz-box-shadow: 0px 3px 6px 0px #000;36 box-shadow: 0px 3px 6px 0px #000;37 outline: none;38 font-family: "Press Start 2P", cursive;39 font-size: 1.2rem;40 cursor: pointer;41}

Winning Page
The card component
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.
The component receives a value to hide, a flipStatus (whether it’s DEFAULT, FLIPPED or MATCHED), and onClick listener to control the click on the card.
The front of the card is by default a question mark to show that the card is hiding something.
The back has the hidden value.
1// src/components/card/index.js23import style from "./style.css";45export default function Card({ hiddenValue, flipStatus, onClick }) {6 return (7 <div class={style.card} data-flipStatus={flipStatus}>8 <button class={style.front} onClick={onClick}>9 ?10 </button>11 <div class={style.back}>{hiddenValue}</div>12 </div>13 );14}
1/* src/components/card/style.css */23.card {4 position: relative;5 transition: 0.3s;6 transform-style: preserve-3d;7}89.card .front,10.card .back {11 position: absolute;12 top: 0;13 left: 0;14 width: 100%;15 height: 100%;16 display: flex;17 align-items: center;18 justify-content: center;19 border-radius: 6px;20 font-size: 3rem;21 backface-visibility: hidden;22 border: 1px solid #864601;2324 /* box shadow */25 box-shadow: 0px 6px 5px 0px #864601;26 -webkit-box-shadow: 0px 6px 5px 0px #864601;27 -moz-box-shadow: 0px 6px 5px 0px #864601;28}2930.card .front {31 background: #e7a61a;32 color: #864601;33 z-index: 2;34 transform: rotateY(0deg);35}3637.card .back {38 background: white;39}4041.card[data-flipStatus="FLIPPED"],42.card[data-flipStatus="MATCHED"],43.back {44 transform: rotateY(180deg);45}4647.card[data-flipStatus="MATCHED"] .back {48 opacity: 0.2;49 border: 3px dashed red;50}
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:

flipStatus = ‘DEFAULT’

flipStatus = ‘FLIPPED’

flipStatus = ‘MATCHED’
The game page
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:
1// src/components/app.js23import { Component } from "preact";4import { Router } from "preact-router";56import Home from "../routes/home";7import Game from "../routes/game";8import Win from "../routes/win";910/**11 * helper function to generate a schuffled array of cards12 */13function generateGridCards() {14 const emojis = ["🚀", "😺", "🐶", "🏈", "📦", "🙊"];1516 return [...emojis, ...emojis]17 .sort(() => Math.random() - Math.random())18 .map((emoji, idx) => ({ key: idx, emoji }));19}2021export default class App extends Component {22 render() {23 return (24 <div id="app">25 <Router onChange={this.handleRoute}>26 <Home path="/" />27 <Game path="/game" cards={generateGridCards()} />28 <Win path="/win" />29 </Router>30 </div>31 );32 }33}34
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:
state.flippedCards = { first: {}, second: {} } : only 2 cards can be flipped at the same time before deciding whether the player has a match or not. Thus, state.flippedCards contains the currently first and second flipped cards.
state.isMatched = {} : is a map of emojis that were matched. Whenever the player matches a pair of cards, the corresponding emoji is set to true in this object. This helps us determine which cards were watched when rendering.
state.score = 0 : the score of the user, which is 0 initially.
To get a card’s flip status (‘DEFAULT’, ‘FLIPPED’, or ‘MATCHED’), we can do so based on state.flippedCards and state.isMatched objects:
If the card is one of the state.flippedCards, then the card is actually flipped.
If the card’s emoji is in the isMatched map, then the card is matched.
Otherwise, the card is just in its default flip status.
When the player flips a card, the following can be done:
If it’s the first flipped card, then we simply set it in the state.flippedCards
If it’s the second flipped card, then we simply set it in the state.flippedCards and check whether we got a match or not.
In case of a mismatch, we simply flip back the cards by resetting the state.flippedCards to its default value.
In case of a match, we wait 500 ms and then add the emoji of the card to the state.isMatched map (so that the user has enough time to see which cards are flipped before the match). We also increment the score and redirect the player to the winning page in case they reached the maximum score.
1// src/routes/game/index.js23import { Component } from "preact";4import { route } from "preact-router";56import Card from "../../components/card";7import style from "./style";89export default class Game extends Component {10 state = {11 flippedCards: { first: {}, second: {} },12 isMatched: {},13 score: 0,14 };1516 getCardFlipStatus = ({ key, emoji }) => {17 const { flippedCards, isMatched } = this.state;1819 if (isMatched[emoji]) {20 return "MATCHED";21 }2223 if ([flippedCards.first.key, flippedCards.second.key].includes(key)) {24 return "FLIPPED";25 }2627 return "DEFAULT";28 };2930 createCardClickListener = (card) => () => {31 this.flipCard(card);32 };3334 flipCard = (card) => {35 const { flippedCards } = this.state;3637 // if it's the first card to be flipped, we don't need38 // to worry about anything else39 const isFirstFlippedCard = Object.keys(flippedCards.first).length === 0;40 if (isFirstFlippedCard) {41 return this.setState({ flippedCards: { ...flippedCards, first: card } });42 }4344 this.flipSecondCard(card);45 };4647 flipSecondCard = (card) => {48 const { flippedCards, isMatched, score } = this.state;4950 // Flip the second and then check after 500 ms whether it's a match51 // or mismatch and handle it52 this.setState({ flippedCards: { ...flippedCards, second: card } });53 setTimeout(() => {54 if (flippedCards.first.emoji === card.emoji) {55 // it's a match56 this.setState({57 score: score + 1,58 isMatched: { ...isMatched, [card.emoji]: true },59 });60 if (score === 5) {61 this.handleWin();62 }63 }6465 // it's a mismatch, so flip the cards back66 this.setState({ flippedCards: { first: {}, second: {} } });67 }, 500);68 };6970 handleWin = () => {71 setTimeout(() => {72 route("/win");73 }, 500);74 };7576 render(props, state) {77 return (78 <div class={style.game}>79 <header class={style.score}>Score: {state.score}</header>80 <div class={style.grid}>81 {props.cards.map((card) => (82 <Card83 hiddenValue={card.emoji}84 flipStatus={this.getCardFlipStatus(card)}85 disabled={false}86 onClick={this.createCardClickListener(card)}87 />88 ))}89 </div>90 </div>91 );92 }93}
When it comes to styling the game’s grid, we have THE perfect case for CSS grid.
1/* src/routes/game/style.css */23.game {4 position: absolute;5 top: 0;6 bottom: 0;7 left: 0;8 right: 0;9 width: 100%;10 height: 100%;11 display: flex;12 flex-direction: column;13 justify-content: space-between;14 align-items: center;15 padding-top: 15px;16 max-height: 568px;17}1819.score {20 text-align: center;21 font-size: 2rem;22 position: relative;23 top: 30px;24}2526.body {27 display: flex;28 align-items: center;29 flex-direction: column;30 width: 100%;31 height: 100%;32}3334.grid {35 display: grid;36 grid-template-columns: repeat(3, 90px);37 grid-template-rows: repeat(4, 90px);38 grid-gap: 10px;39 position: absolute;40 bottom: 10px;41}
And… that’s it! The game should be complete by now! 👻

Game Page

Final icon of the game on iOS
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:
1<!-- src/index.html -->23<!DOCTYPE html>4<html lang="en">5 <head>6 <meta charset="utf-8" />7 <title><%= htmlWebpackPlugin.options.title %></title>8 <meta name="viewport" content="width=device-width,initial-scale=1" />9 <meta name="mobile-web-app-capable" content="yes" />10 <meta name="apple-mobile-web-app-capable" content="yes" />11 <link12 rel="apple-touch-icon"13 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon.png"14 />15 <link16 rel="apple-touch-icon"17 sizes="57x57"18 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-57x57.png"19 />20 <link21 rel="apple-touch-icon"22 sizes="72x72"23 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-72x72.png"24 />25 <link26 rel="apple-touch-icon"27 sizes="76x76"28 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-76x76.png"29 />30 <link31 rel="apple-touch-icon"32 sizes="114x114"33 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-114x114.png"34 />35 <link36 rel="apple-touch-icon"37 sizes="120x120"38 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-120x120.png"39 />40 <link41 rel="apple-touch-icon"42 sizes="144x144"43 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-144x144.png"44 />45 <link46 rel="apple-touch-icon"47 sizes="152x152"48 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-152x152.png"49 />50 <link51 rel="apple-touch-icon"52 sizes="180x180"53 href="<%= htmlWebpackPlugin.files.publicPath %>assets/icons/apple-touch-icon-180x180.png"54 />55 <link56 rel="manifest"57 href="<%= htmlWebpackPlugin.files.publicPath %>manifest.json"58 />59 <% if (htmlWebpackPlugin.options.manifest.theme_color) { %>60 <meta61 name="theme-color"62 content="<%= htmlWebpackPlugin.options.manifest.theme_color %>"63 />64 <% } %> <% for (var chunk of webpack.chunks) { %> <% if (chunk.names.length65 === 1 && chunk.names[0] === 'polyfills') continue; %> <% for (var file of66 chunk.files) { %> <% if (htmlWebpackPlugin.options.preload &&67 file.match(/\.(js|css)$/)) { %>68 <link69 rel="preload"70 href="<%= htmlWebpackPlugin.files.publicPath + file %>"71 as="<%= file.match(/\.css$/)?'style':'script' %>"72 />73 <% } else if (file.match(/manifest\.json$/)) { %>74 <link75 rel="manifest"76 href="<%= htmlWebpackPlugin.files.publicPath + file %>"77 />78 <% } %> <% } %> <% } %>79 </head>80 <body>81 <%= htmlWebpackPlugin.options.ssr({ url: '/' }) %>82 <script83 defer84 src="<%= htmlWebpackPlugin.files.chunks['bundle'].entry %>"85 ></script>86 <script>87 window.fetch ||88 document.write(89 '<script src="<%= htmlWebpackPlugin.files.chunks["polyfills"].entry %>"><\/script>'90 );91 </script>92 </body>93</html>
We can then modify our production NPM scripts in order to use the template:
1"scripts": {2 "build": "preact build --template src/index.html",3 "serve": "npm run build && preact serve"4}
We also need to modify the src/manifest.json file in order to reflect our Android icons and game’s title:
1{2 "name": "Match Game",3 "short_name": "Match Game",4 "start_url": "/",5 "display": "standalone",6 "orientation": "portrait",7 "background_color": "#fff",8 "theme_color": "#fff",9 "icons": [10 {11 "src": "/assets/icons/android-chrome-192x192.png",12 "type": "image/png",13 "sizes": "192x192"14 },15 {16 "src": "/assets/icons/android-chrome-256x256.png",17 "type": "image/png",18 "sizes": "512x512"19 }20 ]21}
We created a beast!
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:
1npm run buildThe 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:
✅ The game’s functional requirements are met.
✅ The game is served over HTTPS: this is supported out of the box by preact-cli.
✅ All pages are responsive on tablets and mobile devices.
✅ The game can be added to the home screen of the user’s device and look like a native app.
✅ Offline Loading: The game can be totally functional offline! Once you add the game to the home screen, you can play it anytime without the need for Internet connection.
✅ Page transitions don’t feel like they block on the network: we literally have instant page transitions
✅ Each page has a URL: this was met by having a route for each page.
Running Google’s Lighthouse tool gives the following:

Results of running Lighthouse tool on the deployed game
