Building an Endless Runner Game with Three.js, Mixamo, Vite, and PlanetScale (Part Five)
Harnessing the power of Netlify's serverless functions and PlanetScale's serverless cloud
Table of contents
- Introduction
- GitHub Repository
- Why save game data to the cloud?
- What is Serverless?
- Setting up PlanetScale
- Creating the Players table
- Serverless Functions With Netlify
- Creating the signup serverless function
- Creating the signin serverless function
- Fetching the top ten High Scores
- Creating the authenticateToken Function
- Creating the save-coins serverless function
- Creating the save-highscore serverless function
- Creating the save-characters-and-coins serverless function
- Creating the overwrite-game-data serverless function
- Updating the User Interface Design
- Calling the Serverless Functions
- Updating the hide Method
- Summary and next steps
Introduction
Hello there,
This article is the last part of a series titled: Building an endless runner game with Threejs, Mixamo, Netlify, and PlanetScale
This series was divided into five sections:
In this part of the series, we will be combining the power of Netlify's serverless functions and PlanetScales MySQL-compatible serverless database platform to:
register a new user
sign in a user
authenticate user
save the user's game data and
fetch the top 10 high-scores
GitHub Repository
The GitHub repository containing the finished code of this game can be found here: github.com/tope-olajide/cave-runner and the game can be played here: cave-runner.netlify.app.
Note that I divided this project into different branches on GitHub. The source code for this section is available here: github.com/tope-olajide/cave-runner/tree/ft... With Netlify, each branch in this repository can be deployed, and the code for this particular branch was deployed here: 6301510a39fc410008a79e5f--ephemeral-cranach..
Why save game data to the cloud?
Until now, we have been using the local storage of our device to save the game data, which works well. However, using the local storage to store the game data on the user's device comes with some limitations. Aside from the fact that it is limited to about 5MB and can only contain strings, if a user clears the browser's cache, all the game data is gone permanently. Not only that, if a user decides to buy a new device or play the game on another device, the user will not be able to access the stored game data on the new device's local storage.
What is Serverless?
The term "serverless" does not actually mean there is no server. The code has to run somewhere. It means that the software developer does not need to worry about setting up the server or maintaining it, as those services will be handled by the cloud provider. This will free the software developers to build and run applications without having to manage the servers.
Serverless functions
Serverless function is a powerful way of adding back-end logic to our apps without having to set up the backend server and update and maintain it. Imagine after building up this project, we decided to add some backend logic to it to save the game data and fetch some high scores. We can either set up a new backend project with Nodejs, Java or maybe PHP as the backend language OR we can write some serverless function in JavaScript and let the host (Netlify in our case) handle the backend for us.
Serverless database
Similar to serverless functions, a serverless database is any database that adheres to the fundamental concepts of the serverless computing pattern. You don’t have to manage any infrastructure or database operations as everything will be handled by the cloud provider (PlanetScale in our case).
PlanetScale
PlanetScale is a MySQL-compatible serverless database platform. It harnesses the power of Vitess to provide an organization with the most scalable MySQL platform without hiring a team of engineers. It also comes with a powerful CLI that gives you full control of almost everything on PlanetScale, from managing your branches to creating and managing your database and many more.
Setting up PlanetScale
Visit planetscale.com. Create a new account. Follow these steps to create a new database:
Click the "Create a database" button
Give your database a name. I named mine
cave_runner
Select a region closer to you.
Click the "Create database" button to deploy the database. We need the database URL string to connect to our newly created database, click on the
connect
button. From the pop-up window, selectNodejs
on the dropdown in front of theconnect with
options, then copy the long URL strings under the.env
tab.
Create a new file called .env
at the root of our project directory then paste the URL string into it: DATABASE_URL=mysql://xxxxxxxxxxx:pscale_pw_xxxxxxxXXXxXXXXXxXxXXXxXXXXXXXXxxxXXXXXX@XXxxxXXXXXxXXXX.us-east-2.psdb.cloud/cave-runner?ssl={"rejectUnauthorized":true}
Creating the Players
table
From your database's overview page, click on the "Branches" tab in the database navigation.
Create the
Players
tables by running the following commands in the web console:
CREATE TABLE `players` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(255),
`country` varchar(255),
`scores` int,
`coins` int,
`characters` text,
`password` varchar(255),
PRIMARY KEY (`id`)
)
To view the newly created table, click on the schema tab, beside the console table.
Serverless Functions With Netlify
Let's create a netlify/functions
folder in our project's root directory, Netlify will access this directory and deploy every code inside it as a serverless function.
One thing to keep in mind about serverless functions is that they must export a function named handler
.
You might encounter an error stating that the serverless function is not a function if you didn't export a handler
function.
Creating the signup
serverless function
Before we create the signup
function, let's install the following packages:
npm i mysql2 jsonwebtoken bcrypt
We'll connect to the database URL and query the database using the mysql2 package. The jsonwebtoken
package will be used to generate a secure JSON Web Token for an authenticated user while bcrypt
will be used to encrypt the user's password before saving it to the database.
We also need to install @netlify/functions
to import Netlify's serverless function typings for TypeScript:
npm install @netlify/functions
Navigate to the netlify/functions
folder we created earlier and create a new typescript file called signup.ts
Let's create a serverless function that registers a new user inside the newly created signup.ts
file:
import { Handler } from '@netlify/functions';
import mysql from 'mysql2/promise';
import bcrypt from 'bcrypt';
import jsonwebtoken from 'jsonwebtoken';
const handler: Handler = async (event) => {
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
const params = JSON.parse(event.body!);
const {
username, password, country, characters,
}: any = params;
if (!username || username.length < 3) {
return {
statusCode: 400, body: 'your username is too short',
};
}
if (!password || password.length < 5) {
return {
statusCode: 400, body: 'Your password is too short',
};
}
if (!country) {
return {
statusCode: 400, body: 'Please select a valid country',
};
}
try {
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const [userExists]:any = await connection.execute(
'SELECT * FROM players WHERE username = ?',
[username.toLowerCase()],
);
if (userExists.length) {
return {
statusCode: 409, body: JSON.stringify({ message: 'User already exists!' }),
};
}
const encryptedPassword = bcrypt.hashSync(password, 10);
const [createdUser] : any = await connection.execute(
'INSERT INTO players( username, password, country, characters) VALUES (?, ?, ?, ?)',
[username.toLowerCase(), encryptedPassword, country, characters],
);
const token = jsonwebtoken.sign(
{
id: createdUser.insertId,
username,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 730,
},
process.env.JWT_SECRET,
);
return {
statusCode: 200, body: JSON.stringify({ token }),
};
} catch (error) {
return { statusCode: 500, body: JSON.stringify({ error: 'Failed fetching data' }) };
}
};
// eslint-disable-next-line import/prefer-default-export
export { handler };
We ensured that the only HTTP request method that can be used to access the signup function is the POST method. Any other method will throw an error. Then we did some verification to check if the user inputs were valid. Additionally, we verified the username's uniqueness by checking if it already existed in the database, and then we encrypted the password. After that, we saved the user's data and then generated a JWT token from the ID and username of the newly created user using JWT_SECRET
.
Inside the .env
file we created earlier, let's create the JWT_SECRET
that will be used to sign the user's token: JWT_SECRET=Your-JWT-Secret
Creating the signin
serverless function
Create a new TypeScript called file signin.ts
in the netlify/functions
folder. Inside it, we will create a new serverless method that authenticates a user. If the login is successful, this method will generate a JWT token, that will be sent to the client.
import { Handler } from '@netlify/functions';
import mysql from 'mysql2/promise';
import bcrypt from 'bcrypt';
import jsonwebtoken from 'jsonwebtoken';
const handler: Handler = async (event) => {
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: 'Method Not Allowed'
};
}
try {
const params = JSON.parse(event.body!);
const { username, password }: any = params;
const connection = await mysql.createConnection(process.env.DATABASE_URL);
Copy code
const [user]: any = await connection.execute(
'SELECT id, username, password, scores, coins, characters FROM players WHERE username = ?',
[username.toLowerCase()]
);
if (!user.length) {
return {
statusCode: 401,
body: JSON.stringify({
message: 'Account Does Not Exist!'
})
};
}
if (bcrypt.compareSync(password, user[0].password)) {
const token = jsonwebtoken.sign(
{
id: user[0].id,
username,
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 730
},
process.env.JWT_SECRET
);
return {
statusCode: 200,
body: JSON.stringify({
token,
coins: user[0].coins,
scores: user[0].scores,
characters: user[0].characters
})
};
}
return {
statusCode: 401,
body: JSON.stringify({
message: 'Invalid Credentials!'
})
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({
message: 'Unable to Log in'
})
};
}
};
// eslint-disable-next-line import/prefer-default-export
export { handler };
We attempts to log the user in by checking their username and password. If the credentials are valid, it returns a JSON Web Token (JWT), the user's scores, coins and characters. If the credentials are invalid or there is an error, it returns an appropriate error message.
Fetching the top ten High Scores
Let's add a new TypeScript file called highscores.ts
to the netlify/functions
folder. This function will fetch the top ten high scores from the database. We will also ensure that only the GET method can access this function endpoint and the user does not have to be authenticated to access it.
import { Handler } from '@netlify/functions';
import mysql from 'mysql2/promise';
require('dotenv').config();
const handler: Handler = async (event) => {
if (event.httpMethod !== 'GET') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
try {
const { DATABASE_URL } = process.env;
const connection = await mysql.createConnection(DATABASE_URL);
const [highscores] = await connection.execute(
'SELECT username, scores, country FROM players ORDER BY scores DESC LIMIT 10',
);
return {
statusCode: 200,
body: JSON.stringify({ highscores }),
};
} catch (error) {
return { statusCode: 500, body: JSON.stringify({ message: 'Unable to fetch High Scores!' }) };
}
};
// eslint-disable-next-line import/prefer-default-export
export { handler };
Creating the authenticateToken
Function
Create a new directory inside the netlify
folder called utils
. We will create a new function that authenticates the user's token. This function is NOT a serverless function, it is just a standard JavaScript function that will be called to verify the user's token.
Let's create a new TypeScript file named authenticateToken.ts
inside the netlify/utils directory. Then add the authenticateToken
function:
import jsonwebtoken from 'jsonwebtoken';
const authenticateToken = (token) => {
if (token) {
const decodedUser = jsonwebtoken.verify(token, process.env.JWT_SECRET, (err, decoded) => {
if (err) {
return null;
}
return decoded;
});
return decodedUser;
}
return null;
};
export default authenticateToken;
Creating the save-coins
serverless function
Let's create another serverless function called save-coins.ts
. This function will be called to save the coins of a user.
import { Handler } from '@netlify/functions';
import mysql from 'mysql2/promise';
// import querystring from 'querystring';
import authenticateToken from './authenticateToken';
require('dotenv').config();
const handler: Handler = async (event) => {
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: 'Method Not Allowed'
};
}
const token = event.headers.authorization;
const params = JSON.parse(event.body!);
const { coins }: any = params;
const user = authenticateToken(token);
if (!user) {
return {
statusCode: 401,
body: 'Failed to authenticate token.'
};
}
try {
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const { id } = user;
const [rows] = await connection.execute(
'UPDATE players SET coins = ? WHERE id = ?',
[coins, id]
);
return {
statusCode: 200,
body: JSON.stringify(rows),
message: 'Coins Saved Successfully!'
};
} catch (error) {
return {
statusCode: 500,
body: String(error),
message: 'unable to save coins'
};
}
};
export { handler };
Creating the save-highscore
serverless function
Let's create another serverless function called save-highscore.ts
. A POST request will be made to this serverless function's endpoint anytime a user has a new high score and needs to overwrite the old score
import { Handler } from '@netlify/functions';
import mysql from 'mysql2/promise';
import authenticateToken from './authenticateToken';
require('dotenv').config();
const handler: Handler = async (event) => {
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
const token = event.headers.authorization;
const params = JSON.parse(event.body!);
const { scores }: any = params;
const user = authenticateToken(token);
if (!user) {
return { statusCode: 401, body: 'Failed to authenticate token.' };
}
try {
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const { id } = user;
const [rows] = await connection.execute(
'UPDATE players SET scores = ? WHERE id = ?',
[scores, id],
);
return {
statusCode: 200,
body: JSON.stringify(rows),
message: 'Score Saved Successfully!',
};
} catch (error) {
return { statusCode: 500, body: String(error), message: 'unable to save scores' };
}
};
// eslint-disable-next-line import/prefer-default-export
export { handler };
Creating the save-characters-and-coins
serverless function
The API endpoint for this function will be invoked anytime a user unlocks a new character. It will save the updated characters and the remaining coins.
import { Handler } from '@netlify/functions';
import mysql from 'mysql2/promise';
import authenticateToken from './authenticateToken';
require('dotenv').config();
const handler: Handler = async (event) => {
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: 'Method Not Allowed'
};
}
const token = event.headers.authorization;
const params = JSON.parse(event.body!);
const { characters, coins }: any = params;
try {
const user = authenticateToken(token);
if (!user) {
return {
statusCode: 401,
body: 'Failed to authenticate token.'
};
}
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const { id } = user;
const [rows] = await connection.execute(
'UPDATE players SET characters = ?, coins = ? WHERE id = ?',
[characters, coins, id]
);
return {
statusCode: 200,
body: JSON.stringify(rows),
message: 'Coins Saved Successfully!'
};
} catch (error) {
return {
statusCode: 500,
body: String(error),
message: 'unable to save coins'
};
}
};
export { handler };
Creating the overwrite-game-data
serverless function
Let's create another serverless function file and name it overwrite-game-data.ts
.This function will be invoked once the game detects a difference between the game data saved on the user's device and the game data saved online. Once a user agrees to overwrite the online data with the one on their device, this function endpoint will be called.
import { Handler } from '@netlify/functions';
import mysql from 'mysql2/promise';
import authenticateToken from './authenticateToken';
const handler: Handler = async (event) => {
if (event.httpMethod !== 'POST') {
return { statusCode: 405, body: 'Method Not Allowed' };
}
const token = event.headers.authorization;
const params = JSON.parse(event.body!);
const { characters }: any = params;
const { scores }: any = params;
const { coins }: any = params;
const user = authenticateToken(token);
if (!user) {
return { statusCode: 401, body: 'Failed to authenticate token.' };
}
try {
const connection = await mysql.createConnection(process.env.DATABASE_URL);
const { id } = user;
const [rows] = await connection.execute(
'UPDATE players SET characters = ?, scores= ?, coins = ? WHERE id = ?',
[characters, scores, coins, id],
);
return {
statusCode: 200,
body: JSON.stringify(rows),
message: 'characters Saved Successfully!',
};
} catch (error) {
return { statusCode: 500, body: String(error), message: 'unable to save characters' };
}
};
// eslint-disable-next-line import/prefer-default-export
export { handler };
Updating the User Interface Design
In this part, we are going to add some HTML elements to the index.html
file. Let's install the flag-icons by running:
npm I flag-icons
The flags of the top ten runners' countries will be shown using flag-icons
.
Creating the Signup
form
Let's create the signup form by adding the following code to the index.html
file:
<div id="sign-up-modal" class="modal">
<div class="modal-body">
<div class="form">
<h2 class="close" id="close-signup-form"><span><i class="fa-solid fa-times"></i></span></h2>
<h2>Sign Up</h2>
<p>username:</p>
<input id="signup-username-text" type="text" value="">
<p>password:</p>
<input id="signup-password-text" type="password" value="">
<p>Repeat password:</p>
<input id="signup-repeat-password-text" type="password" value="">
<p>Rep Your Country:</p>
<select id="country" name="country">
<option value="NG">Nigeria</option>
<option value="AF">Afghanistan</option>
<option value="AX">Aland Islands</option>
<option value="AL">Albania</option>
<option value="DZ">Algeria</option>
<option value="AS">American Samoa</option>
<option value="AD">Andorra</option>
<option value="AO">Angola</option>
<option value="AI">Anguilla</option>
<option value="AQ">Antarctica</option>
<option value="AG">Antigua and Barbuda</option>
<option value="AR">Argentina</option>
<option value="AM">Armenia</option>
<option value="AW">Aruba</option>
<option value="AU">Australia</option>
<option value="AT">Austria</option>
<option value="AZ">Azerbaijan</option>
<option value="BS">Bahamas</option>
<option value="BH">Bahrain</option>
<option value="BD">Bangladesh</option>
<option value="BB">Barbados</option>
<option value="BY">Belarus</option>
<option value="BE">Belgium</option>
<option value="BZ">Belize</option>
<option value="BJ">Benin</option>
<option value="BM">Bermuda</option>
<option value="BT">Bhutan</option>
<option value="BO">Bolivia</option>
<option value="BQ">Bonaire, Sint Eustatius and Saba</option>
<option value="BA">Bosnia and Herzegovina</option>
<option value="BW">Botswana</option>
<option value="BV">Bouvet Island</option>
<option value="BR">Brazil</option>
<option value="IO">British Indian Ocean Territory</option>
<option value="BN">Brunei Darussalam</option>
<option value="BG">Bulgaria</option>
<option value="BF">Burkina Faso</option>
<option value="BI">Burundi</option>
<option value="KH">Cambodia</option>
<option value="CM">Cameroon</option>
<option value="CA">Canada</option>
<option value="CV">Cape Verde</option>
<option value="KY">Cayman Islands</option>
<option value="CF">Central African Republic</option>
<option value="TD">Chad</option>
<option value="CL">Chile</option>
<option value="CN">China</option>
<option value="CX">Christmas Island</option>
<option value="CC">Cocos (Keeling) Islands</option>
<option value="CO">Colombia</option>
<option value="KM">Comoros</option>
<option value="CG">Congo</option>
<option value="CD">Congo, Democratic Republic of the Congo</option>
<option value="CK">Cook Islands</option>
<option value="CR">Costa Rica</option>
<option value="CI">Cote D'Ivoire</option>
<option value="HR">Croatia</option>
<option value="CU">Cuba</option>
<option value="CW">Curacao</option>
<option value="CY">Cyprus</option>
<option value="CZ">Czech Republic</option>
<option value="DK">Denmark</option>
<option value="DJ">Djibouti</option>
<option value="DM">Dominica</option>
<option value="DO">Dominican Republic</option>
<option value="EC">Ecuador</option>
<option value="EG">Egypt</option>
<option value="SV">El Salvador</option>
<option value="GQ">Equatorial Guinea</option>
<option value="ER">Eritrea</option>
<option value="EE">Estonia</option>
<option value="ET">Ethiopia</option>
<option value="FK">Falkland Islands (Malvinas)</option>
<option value="FO">Faroe Islands</option>
<option value="FJ">Fiji</option>
<option value="FI">Finland</option>
<option value="FR">France</option>
<option value="GF">French Guiana</option>
<option value="PF">French Polynesia</option>
<option value="TF">French Southern Territories</option>
<option value="GA">Gabon</option>
<option value="GM">Gambia</option>
<option value="GE">Georgia</option>
<option value="DE">Germany</option>
<option value="GH">Ghana</option>
<option value="GI">Gibraltar</option>
<option value="GR">Greece</option>
<option value="GL">Greenland</option>
<option value="GD">Grenada</option>
<option value="GP">Guadeloupe</option>
<option value="GU">Guam</option>
<option value="GT">Guatemala</option>
<option value="GG">Guernsey</option>
<option value="GN">Guinea</option>
<option value="GW">Guinea-Bissau</option>
<option value="GY">Guyana</option>
<option value="HT">Haiti</option>
<option value="HM">Heard Island and Mcdonald Islands</option>
<option value="VA">Holy See (Vatican City State)</option>
<option value="HN">Honduras</option>
<option value="HK">Hong Kong</option>
<option value="HU">Hungary</option>
<option value="IS">Iceland</option>
<option value="IN">India</option>
<option value="ID">Indonesia</option>
<option value="IR">Iran, Islamic Republic of</option>
<option value="IQ">Iraq</option>
<option value="IE">Ireland</option>
<option value="IM">Isle of Man</option>
<option value="IL">Israel</option>
<option value="IT">Italy</option>
<option value="JM">Jamaica</option>
<option value="JP">Japan</option>
<option value="JE">Jersey</option>
<option value="JO">Jordan</option>
<option value="KZ">Kazakhstan</option>
<option value="KE">Kenya</option>
<option value="KI">Kiribati</option>
<option value="KP">Korea, Democratic People's Republic of</option>
<option value="KR">Korea, Republic of</option>
<option value="XK">Kosovo</option>
<option value="KW">Kuwait</option>
<option value="KG">Kyrgyzstan</option>
<option value="LA">Lao People's Democratic Republic</option>
<option value="LV">Latvia</option>
<option value="LB">Lebanon</option>
<option value="LS">Lesotho</option>
<option value="LR">Liberia</option>
<option value="LY">Libyan Arab Jamahiriya</option>
<option value="LI">Liechtenstein</option>
<option value="LT">Lithuania</option>
<option value="LU">Luxembourg</option>
<option value="MO">Macao</option>
<option value="MK">Macedonia, the Former Yugoslav Republic of</option>
<option value="MG">Madagascar</option>
<option value="MW">Malawi</option>
<option value="MY">Malaysia</option>
<option value="MV">Maldives</option>
<option value="ML">Mali</option>
<option value="MT">Malta</option>
<option value="MH">Marshall Islands</option>
<option value="MQ">Martinique</option>
<option value="MR">Mauritania</option>
<option value="MU">Mauritius</option>
<option value="YT">Mayotte</option>
<option value="MX">Mexico</option>
<option value="FM">Micronesia, Federated States of</option>
<option value="MD">Moldova, Republic of</option>
<option value="MC">Monaco</option>
<option value="MN">Mongolia</option>
<option value="ME">Montenegro</option>
<option value="MS">Montserrat</option>
<option value="MA">Morocco</option>
<option value="MZ">Mozambique</option>
<option value="MM">Myanmar</option>
<option value="NA">Namibia</option>
<option value="NR">Nauru</option>
<option value="NP">Nepal</option>
<option value="NL">Netherlands</option>
<option value="AN">Netherlands Antilles</option>
<option value="NC">New Caledonia</option>
<option value="NZ">New Zealand</option>
<option value="NI">Nicaragua</option>
<option value="NE">Niger</option>
<option value="NU">Niue</option>
<option value="NF">Norfolk Island</option>
<option value="MP">Northern Mariana Islands</option>
<option value="NO">Norway</option>
<option value="OM">Oman</option>
<option value="PK">Pakistan</option>
<option value="PW">Palau</option>
<option value="PS">Palestinian Territory, Occupied</option>
<option value="PA">Panama</option>
<option value="PG">Papua New Guinea</option>
<option value="PY">Paraguay</option>
<option value="PE">Peru</option>
<option value="PH">Philippines</option>
<option value="PN">Pitcairn</option>
<option value="PL">Poland</option>
<option value="PT">Portugal</option>
<option value="PR">Puerto Rico</option>
<option value="QA">Qatar</option>
<option value="RE">Reunion</option>
<option value="RO">Romania</option>
<option value="RU">Russian Federation</option>
<option value="RW">Rwanda</option>
<option value="BL">Saint Barthelemy</option>
<option value="SH">Saint Helena</option>
<option value="KN">Saint Kitts and Nevis</option>
<option value="LC">Saint Lucia</option>
<option value="MF">Saint Martin</option>
<option value="PM">Saint Pierre and Miquelon</option>
<option value="VC">Saint Vincent and the Grenadines</option>
<option value="WS">Samoa</option>
<option value="SM">San Marino</option>
<option value="ST">Sao Tome and Principe</option>
<option value="SA">Saudi Arabia</option>
<option value="SN">Senegal</option>
<option value="RS">Serbia</option>
<option value="CS">Serbia and Montenegro</option>
<option value="SC">Seychelles</option>
<option value="SL">Sierra Leone</option>
<option value="SG">Singapore</option>
<option value="SX">Sint Maarten</option>
<option value="SK">Slovakia</option>
<option value="SI">Slovenia</option>
<option value="SB">Solomon Islands</option>
<option value="SO">Somalia</option>
<option value="ZA">South Africa</option>
<option value="GS">South Georgia and the South Sandwich Islands</option>
<option value="SS">South Sudan</option>
<option value="ES">Spain</option>
<option value="LK">Sri Lanka</option>
<option value="SD">Sudan</option>
<option value="SR">Suriname</option>
<option value="SJ">Svalbard and Jan Mayen</option>
<option value="SZ">Swaziland</option>
<option value="SE">Sweden</option>
<option value="CH">Switzerland</option>
<option value="SY">Syrian Arab Republic</option>
<option value="TW">Taiwan, Province of China</option>
<option value="TJ">Tajikistan</option>
<option value="TZ">Tanzania, United Republic of</option>
<option value="TH">Thailand</option>
<option value="TL">Timor-Leste</option>
<option value="TG">Togo</option>
<option value="TK">Tokelau</option>
<option value="TO">Tonga</option>
<option value="TT">Trinidad and Tobago</option>
<option value="TN">Tunisia</option>
<option value="TR">Turkey</option>
<option value="TM">Turkmenistan</option>
<option value="TC">Turks and Caicos Islands</option>
<option value="TV">Tuvalu</option>
<option value="UG">Uganda</option>
<option value="UA">Ukraine</option>
<option value="AE">United Arab Emirates</option>
<option value="GB">United Kingdom</option>
<option value="US">United States</option>
<option value="UM">United States Minor Outlying Islands</option>
<option value="UY">Uruguay</option>
<option value="UZ">Uzbekistan</option>
<option value="VU">Vanuatu</option>
<option value="VE">Venezuela</option>
<option value="VN">Viet Nam</option>
<option value="VG">Virgin Islands, British</option>
<option value="VI">Virgin Islands, U.s.</option>
<option value="WF">Wallis and Futuna</option>
<option value="EH">Western Sahara</option>
<option value="YE">Yemen</option>
<option value="ZM">Zambia</option>
<option value="ZW">Zimbabwe</option>
</select>
<p>Already have an account?<button id="sign-in-button" class="btn"><span class="btn-icon"><i
class="fa-solid fa-arrow-right-to-bracket"></i></span> Sign in</button></p>
<button id="register-button" class="btn">Register</button>
</div>
</div>
</div>
The signup form is a modal wrapped in a div element with the class "modal" and an id of "sign-up-modal". It includes fields for a username, password, repeated password, and a dropdown menu for the user to select their country. There is also a "close" button to close the form.
Creating the Signin
form
Let's create another HTML form in the index.html
for a user to login into their account:
<div id="sign-in-modal" class="modal">
<div class="modal-body">
<div class="form">
<h2 id="close-signin-form" class="close"><span><i class="fa-solid fa-times"></i></span></h2>
<h2>Sign In</h2>
<input id="signin-username-text" type="text" value="username"><br />
<input id="signin-password-text" type="password" value="password">
<p>Don't have an account?<button id="sign-up-button" class="btn"><span class="btn-icon"><i
class="fa-solid fa-user-plus"></i></span> Sign up</button></p>
<button id="login-button" class="btn">Login</button>
</div>
</div>
</div>
The login form includes fields for a username and password, as well as a "Sign up" button for users who do not have an account. There is also a "close" button to close the form. The form is wrapped in a div element with the class "modal" and an id of "sign-in-modal".
Creating the highscores
modal
The highscores
modal contains the HTML table that displays the top runners, along with their rank, name, scores, and country flag.
<div id="high-scores-modal" class="modal">
<div class="modal-body">
<h2 class="close" id="close-highscores-modal"><span><i class="fa-solid fa-times"></i></span></h2>
<h3>Top 10 Runners</h3>
<table id="rank-table">
<tr>
<th>Rank</th>
<th>Name</th>
<th>Scores</th>
<th>Country</th>
</tr>
</table>
</div>
</div>
Game Data Conflicts
Let's create a modal that will be used to display a message when there is a conflict between the backed up game data and the data on the device.
<div id="backup-modal" class="modal">
<div class="modal-body">
<div class="backup-content">
<h2>Game Data Conflicts</h2>
<p>We observed that your backed up game data is not the same as the data on your device. </p>
<p>What do you want to do?</p>
<button class="btn" id="restore-online-backup-btn"> Restore my online backup and overwrite my local
data.</button>
<button class="btn" id="overwrite-online-backup-btn"> Overwrite my online backup with the game data saved on
this device</button>
</div>
</div>
</div>
The modal includes two buttons that allow the user to either restore the online backup and overwrite the local data, or overwrite the online backup with the data on the device.
Creating the fetch high score button
Let's create a button that will be used to display the high scores table once a user clicks on it.
<button id="score-board-button" class="btn"> <span class="btn-icon"><i class="fa-solid fa-crown"></i></span> High Scores </button>
Creating the auto-save-loader
Spinner
The auto-save-loader
is a font-awesome spinner that shows up when the game is saving the user's data online.
<div class="auto-save-loader fa-2x"><i class="fas fa-spinner fa-spin"></i></div>
Updating the style.css
file
Let's now open the "style.css" file and make the necessary updates to the styles of the HTML elements we created earlier :
html,
body {
overflow: hidden;
margin: 0px;
height: 100%;
font-family: lato, sans-serif;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
width: 100%;
height: 100%;
display: block;
}
.high-score-container,
.total-coins-container,
.coins-container,
.scores-container {
position: absolute;
top: 0;
color: #F2E205;
right: 0;
padding: 0px 50px;
background-color: #00000063;
display: none;
}
.high-score-container,
.coins-container {
margin-top: 40px;
}
.coins-container h3,
.scores-container h3 {
margin: .5rem;
}
.pause-button,
.home-menu {
position: absolute;
left: 0;
top: 0;
margin: 20px 10px;
display: none;
}
.modal {
display: none;
position: fixed;
z-index: 1;
padding-top: 100px;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
}
.modal-body {
background-color: #BA4D31;
margin: 50px auto;
padding: 10px;
border: 10px solid #2C0604;
width: 300px;
opacity: 0.8;
color: #2C0604;
}
.btn,
.btn-icon-only,
.btn-two {
background-color: #2C0604;
border: none;
color: #BA4D31;
padding: 12px 12px 12px 0;
font-size: 16px;
cursor: pointer;
margin: 5px;
}
.btn-two {
padding: 12px 0 12px 12px;
}
.btn-icon-only {
padding: 0px;
color: #F2E205;
}
.btn-icon {
background-color: #4e2828;
padding: 12px;
}
.btn:hover {
opacity: .9;
}
#game-paused-modal {
display: none;
text-align: center;
}
#game-over-modal {
text-align: center;
}
#game-over-modal h1 {
font-size: 1.5rem;
margin: 0;
}
#game-over-modal p {
font-size: 1.3rem;
margin: 0.8rem;
}
.disable-touch {
width: 100%;
height: 100%;
position: absolute;
display: none;
}
.loading-container {
width: 100%;
height: 100%;
position: absolute;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
background-color: rgba(0, 0, 0, 0.993);
}
#loading-bar {
color: #BA4D31;
height: 5px;
border-radius: 10px;
border: 1px solid rgb(22, 21, 20);
margin: 0;
}
.loading-text {
color: #BA4D31;
font-size: 1.8rem;
margin-bottom: -12px;
font-weight: 700;
text-transform: uppercase;
}
.loading-percentage {
font-size: 4rem;
color: white;
font-weight: 900;
margin: 0;
padding: 0;
}
/* Main Menu buttons */
#main-menu-buttons {
position: absolute;
bottom: 0;
margin: 0 auto;
left: 0;
right: 0;
display: flex;
text-align: center;
display: none;
}
#main-menu-buttons button {
margin-bottom: 20px;
}
#character-price-button {
display: none;
}
.character-selection-buttons {
position: absolute;
right: 0;
bottom: 0;
display: none;
justify-content: space-evenly;
width: 100%;
margin-bottom: 20px;
align-items: center;
display: flex;
}
.character-name {
text-align: center;
color: #F2E205;
font-size: 1.5rem;
position: absolute;
bottom: 0;
width: 100%;
margin-bottom: 80px;
display: block;
}
#character-price-button {
display: none;
}
#character-selection-container {
display: none;
}
.auth-button {
position: absolute;
left: 0;
top: 0;
margin: 30px 10px;
display: none;
}
#sign-up-modal {
display: none;
padding-top: 20px;
}
#sign-in-modal {
display: none;
}
.form {
display: flex;
flex-direction: column;
padding: 5px;
text-align: center;
}
input {
padding: 8px;
margin-bottom: 5px;
}
.form p {
text-align: left;
margin: .5rem 0;
color: black;
font-weight: 500;
}
.close {
text-align: right;
cursor: pointer;
font-weight: 700;
}
.close h2,
.form h2 {
margin: 0;
}
.form h2 {
margin-bottom: 10px;
}
#sign-out-button {
display: none;
position: absolute;
left: 0;
top: 0;
margin: 60px 20px;
}
#greetings {
position: absolute;
top: 0;
color: #F2E205;
left: 0;
margin: 20px;
display: none;
}
#score-board-button {
margin: 150px 10px;
right: 0;
display: none;
position: absolute;
top: 0;
}
#high-scores-modal {
display: none;
}
#high-scores-modal {
margin-top: 0;
padding: 0;
}
#high-scores-modal h3 {
text-align: center;
font-size: 1.5rem;
margin: 10px;
}
#rank-table {
border-collapse: collapse;
border: 1px solid #4e2828;
color: aliceblue;
margin: 0 auto;
}
.close-highscores-modal {
margin: 0;
}
th,
td {
text-align: center;
padding: 10px 10px;
}
tr:nth-child(even) {
background-color: #4e2828
}
#country {
padding: 8px 0;
}
#about-modal {
display: none;
padding-top: 50px;
}
.about-content {
text-align: center;
}
.auto-save-loader {
position: absolute;
color: white;
bottom: 0;
right: 0;
margin: 80px 50px;
display: none;
}
@media screen and (max-width: 480px) {
.btn,
.btn-icon-only,
.btn-two {
padding: 10px 10px 10px 0;
font-size: 10px;
}
.btn-two {
padding: 10px 0 10px 10px;
}
.btn-icon {
background-color: #4e2828;
padding: 10px;
}
#main-menu-buttons button {
margin-bottom: 10px;
}
h3 {
font-size: 1rem;
margin: 0.4rem 0;
}
}
#username {
text-transform: capitalize;
}
#backup-modal {
display: none;
}
.backup-content {
text-align: center;
}
#backup-modal .btn {
border-radius: 8px;
}
Calling the Serverless Functions
It's really simple to call the netlify function. The name of the function files we created determines the URL that they get. For example the URL of the signup
function, will be served at localhost:3000/.netlify/functions/signup
.
Signing up the user
Open up the MainMenuScene.ts
file and create a new method that displays the signup form:
private displaySignUpForm() {
(document.querySelector('#sign-in-modal') as HTMLInputElement).style.display='none';
(document.querySelector('#sign-up-modal') as HTMLInputElement).style.display='block';
}
Next, we'll create another method that closes the form:
private closeSignUpForm() {
(document.querySelector('#sign-up-modal') as HTMLInputElement).style.display='none';
}
Then, we will create a new method calledsignUpUser
. This method will get the user's registration data and make a POST request to the /.netlify/functions/signup
URL. Notice how we didn't add localhost:3000
or any domain name to /.netlify/functions/signup
, that is because the serverless function is being hosted on the same domain name, so there is no reason to add it. After a user has been registered successfully, a token will be sent to the browser which is then saved to the localstorage.
It is very important to note that saving tokens on the localstorage is not advisable because it makes the website susceptible to XXS attack.
async signUpUser() {
const username = (document.getElementById('signup-username-text') as HTMLInputElement).value;
const password = (document.getElementById('signup-password-text') as HTMLInputElement).value;
const repeatPassword = (document.getElementById('signup-repeat-password-text') as HTMLInputElement).value;
const country = (document.getElementById('country') as HTMLInputElement).value;
const characters = JSON.stringify(this.allGameCharacters);
const signUpData = {
username, password, country, characters,
};
if (username.length < 4) {
Toastify({
text: '❎ Username is too short!',
duration: 3000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
} else if (password.length < 5) {
Toastify({
text: '❎ Password is too short!',
duration: 3000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
} else if (password !== repeatPassword) {
Toastify({
text: '❎ Password does not match!',
duration: 3000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
} else {
try {
(document.querySelector('#register-button') as HTMLInputElement).innerHTML = 'Signing you up...';
(document.querySelector('#register-button') as HTMLInputElement).disabled = true;
const response = await fetch('/.netlify/functions/signup', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(signUpData),
});
const { token, message } = await response.json();
(document.querySelector('#register-button') as HTMLInputElement).innerHTML = 'Register';
(document.querySelector('#register-button') as HTMLInputElement).disabled = false;
if (token) {
localStorage.setItem('token', token);
localStorage.setItem('username', username);
this.closeSignUpForm();
this.loadLoginScreen();
Toastify({
text: 'Registration Successful!',
duration: 4000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
} else {
Toastify({
text: `${message}`,
duration: 4000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
}
} catch (error) {
Toastify({
text: '❎❎❎ Unable to sign you up, please try again.',
duration: 3000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
(document.querySelector('#register-button') as HTMLInputElement).innerHTML = 'Signing you up...';
(document.querySelector('#register-button') as HTMLInputElement).disabled = false;
}
}
}
In the initialize
method, we will call the displaySignUpForm
whenever the auth-button
is clicked: javascript
(document.querySelector('.auth-button') as HTMLInputElement).onclick = () => { this.displaySignUpForm(); };
Then, we will call the closeSignUpForm
method once the close-signup-form
button is clicked javascript
(document.querySelector('#close-signup-form') as HTMLInputElement).onclick = () => { this.closeSignUpForm();
};
Finally, let's call the signUpUser()
method when the register-button
is clicked.
(document.querySelector('#register-button') as HTMLInputElement).onclick = () => { this.signUpUser(); };
Signing in a User
To authenticate a user with an existing account, we will first create a method that shows a sign-in form in a modal when it is invoked.
private displaySignInForm() {
(document.querySelector('#sign-up-modal') as HTMLInputElement).style.display = 'none';
(document.querySelector('#sign-in-modal') as HTMLInputElement).style.display = 'block';
}
Next, we'll create another method that closes the sign-in form modal.
private closeSignInForm = () => { (document.querySelector('#sign-in-modal') as HTMLInputElement).style.display = 'none'; };
After that, a new method called loadLoginScreen
will be created. This method will display a welcoming text and the sign-out
button. Additionally, the method will hide the auth-button
private loadLoginScreen() {
(document.querySelector('#sign-out-button') as HTMLInputElement).style.display = 'block';
(document.querySelector('#greetings') as HTMLInputElement).style.display = 'block';
(document.querySelector('.auth-button') as HTMLInputElement).style.display = 'none';
(document.querySelector('#username') as HTMLInputElement).innerHTML = localStorage.getItem('username')!;
}
We will then create a new method called signInUser
which will send a POST request to the sign-in serverless function to verify the user's credentials. Once the user has been authenticated, the system will compare the game data stored on the user's device to the data stored online. If the data differs, the system will display a backup modal and give the user the option to either keep the local data or overwrite it with the online data.
private async signInUser() {
const username = (document.getElementById('signin-username-text') as HTMLInputElement).value;
const password = (document.getElementById('signin-password-text') as HTMLInputElement).value;
const loginData = { username, password };
try {
(document.querySelector('#login-button') as HTMLInputElement).innerHTML = 'Logging you in...';
(document.querySelector('#login-button') as HTMLInputElement).disabled = true;
const response = await fetch('/.netlify/functions/signin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(loginData),
});
const {
token, message, scores, coins, characters,
} = await response.json();
(document.querySelector('#login-button') as HTMLInputElement).innerHTML = 'Login';
(document.querySelector('#login-button') as HTMLInputElement).disabled = false;
if (token) {
localStorage.setItem('token', token);
localStorage.setItem('username', username);
this.closeSignInForm();
this.loadLoginScreen();
Toastify({
text: `Welcome Back, ${username}`,
duration: 4000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
if (scores !== Number(JSON.parse(localStorage.getItem('high-score')!))
|| coins !== Number(JSON.parse(localStorage.getItem('total-coins')!))
|| characters !== JSON.stringify(this.allGameCharacters)) {
(document.querySelector('#backup-modal') as HTMLInputElement).style.display = 'block';
}
(document.querySelector('#restore-online-backup-btn') as HTMLInputElement).onclick = () => {
this.restoreOnlineBackup(scores, coins, characters);
};
} else {
Toastify({
text: `${message}`,
duration: 4000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
}
} catch (error) {
(document.querySelector('#login-button') as HTMLInputElement).innerHTML = 'Login';
(document.querySelector('#login-button') as HTMLInputElement).disabled = false;
Toastify({
text: '❎❎❎ Unable to login, please try again',
duration: 3000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
}
}
In the initialize
method of the MainMenuScene
method, we will call the closeSignInForm
method anytime the #close-signin-form
button is clicked
(document.querySelector('#close-signin-form') as HTMLInputElement).onclick = () => { this.closeSignInForm(); };
Then call the displaySignInForm
method anytime the #sign-in-button
is clicked
(document.querySelector('#sign-in-button') as HTMLInputElement).onclick = () => { this.displaySignInForm(); };
Finally, let's call the signInUser
method anytime the #login-button
is clicked:
(document.querySelector('#login-button') as HTMLInputElement).onclick = () => { this.signInUser(); };
Logging out the User
Let's create a new method called: logoutUser
. This method will sign the user out by deleting the token and username from the local storage and calling the loadLogoutScreen
method.
private logoutUser() { localStorage.removeItem('token'); localStorage.removeItem('username'); this.loadLogoutScreen(); }
After that, we'll create the loadLogoutScreen
, this method will hide the #sign-out-button
and #greetings
text then displays the auth-button
:
private loadLogoutScreen() { (document.querySelector('#sign-out-button') as HTMLInputElement).style.display = 'none'; (document.querySelector('#greetings') as HTMLInputElement).style.display = 'none'; (document.querySelector('.auth-button') as HTMLInputElement).style.display = 'block'; }
Back to the initialize
method, we will invoke the logoutUser
method anytime the user clicks on the #sign-out-button
:
javascript (document.querySelector('#sign-out-button') as HTMLInputElement).onclick = () => { this.logoutUser(); };
Displaying the top ten high-scores
The top ten high scores will be fetched from the database by making a GET request to the highscore
serverless function's endpoint. The HTML table we previously constructed will show the users' ranking positions, usernames, scores, and flags of their respective countries.
private async fetchHighScores() {
(document.querySelector('#high-scores-modal') as HTMLInputElement).style.display = 'block';
const response = await fetch('/.netlify/functions/highscores');
const { highscores } = await response.json();
let tableHead = '<tr><th>Rank</th><th>Username</th><th>Scores</th><th>Country</th></tr>';
highscores.forEach((player: {
username: string; scores: string; country: string;
}, index: number) => {
tableHead += `<tr>
<td>${index + 1} </td>
<td>${player.username}</td>
<td>${player.scores}</td>
<td><span class='fi fi-${player.country.toLowerCase()}'></span></td>
</tr>`;
});
(document.getElementById('rank-table') as HTMLInputElement).innerHTML = tableHead;
}
After that, we'll create a method called closeHighScoreModal
. This method will close the high-scores-modal
anytime it is invoked:
private closeHighScoreModal() { (document.querySelector('#high-scores-modal') as HTMLInputElement).style.display = 'none'; }
In the initialize method, let's add the following code to call the fetchHighScores
method anytime the #score-board-button
is pressed and invoke the closeHighScoreModal method anytime the #close-highscores-modal
is clicked
(document.querySelector('#score-board-button') as HTMLInputElement).onclick = () => {
this.fetchHighScores();
};
(document.querySelector('#close-highscores-modal') as HTMLInputElement).onclick = () => {
this.closeHighScoreModal();
};
Updating the hide
Method
Let's update the hide
method of the MainMenuScene
to conceal the auth-button
, #score-board-button
, sign-out-button
, and the #greetings
text :
(document.querySelector('.auth-button') as HTMLInputElement).style.display = 'none'; (document.querySelector('#score-board-button') as HTMLInputElement).style.display = 'none'; (document.querySelector('#sign-out-button') as HTMLInputElement).style.display = 'none'; (document.querySelector('#greetings') as HTMLInputElement).style.display = 'none';
Saving the Characters and Coins in the CharacterSelectionScene
Let's open up the CharacterSelectionScene
and add the following code to the activateCharacter
method:
if (token) {
try {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'block';
const response = await fetch('/.netlify/functions/save-characters-and-coins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: token,
},
body: JSON.stringify({ characters: JSON.stringify(updatedPlayerData), coins: Number(localStorage.getItem('total-coins')) }),
});
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
if (response.status === 401) {
localStorage.removeItem('token');
if (response.status === 401) {
Toastify({
text: 'Your session has expired. Please relogin',
duration: 5000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
}
}
} catch (error) {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
}
}
Previously, when a character was unlocked, the updated character data and remaining coins would be saved to the local storage. However, we have now updated the process to save this data to the cloud by making a POST request to the save-characters-and-coins
serverless function endpoint with the updated character information and coin count.
The activateCharacter
method should be looking like this :
async activateCharacter() {
const savedPlayerData: IallGameCharacters[] = JSON.parse(localStorage.getItem('allGameCharacters')!);
const updatedPlayerData = savedPlayerData.map((playerInfo, index: number) => {
if (this.activeIndexNumber === index) {
return {
...playerInfo,
isActive: true,
price: 0,
isLocked: false,
};
}
return {
...playerInfo,
isActive: false
};
});
localStorage.setItem('allGameCharacters', JSON.stringify(updatedPlayerData));
this.allGameCharacters = updatedPlayerData;
const token = localStorage.getItem('token');
if (token) {
try {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'block';
const response = await fetch('/.netlify/functions/save-characters-and-coins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: token,
},
body: JSON.stringify({
characters: JSON.stringify(updatedPlayerData),
coins: Number(localStorage.getItem('total-coins'))
}),
});
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
if (response.status === 401) {
localStorage.removeItem('token');
if (response.status === 401) {
Toastify({
text: 'Your session has expired. Please relogin',
duration: 5000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
}
}
} catch (error) {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
}
}
}
Saving The High Scores and Coins in the RunningScene
In the RunningScene.ts
file, we will modify the saveHighScore
and saveCoins
methods to store the high score and total coins in the database
, rather than just saving them to the local storage. This change will occur whenever the game is over.
Let us update the saveHighScore
method to make a POST request to the save-highscore
serverless function and save the high score to the database:
private async saveHighScore() {
const highScore = localStorage.getItem('high-score') || 0;
if (Number(this.scores) > Number(highScore)) {
localStorage.setItem('high-score', this.scores.toString());
const token = localStorage.getItem('token');
if (token) {
try {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'block';
const response = await fetch('/.netlify/functions/save-highscore', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: token,
},
body: JSON.stringify({
scores: this.scores
}),
});
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
if (response.status === 401) {
localStorage.removeItem('token');
}
} catch (error) {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
}
}
}
}
Let's update the saveCoins
method to save the user's coins to the database by making a POST request to the save-coins
serverless function:
private async saveCoins() {
const prevTotalCoins = localStorage.getItem('total-coins') || 0;
const totalCoins = Number(prevTotalCoins) + this.coins;
localStorage.setItem('total-coins', totalCoins.toString());
const token = localStorage.getItem('token');
if (token) {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'block';
try {
const response = await fetch('/.netlify/functions/save-coins', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
authorization: token,
},
body: JSON.stringify({
coins: totalCoins
}),
});
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
if (response.status === 401) {
Toastify({
text: 'Your session has expired. Please relogin',
duration: 5000,
close: true,
gravity: 'bottom',
position: 'center',
stopOnFocus: true,
}).showToast();
localStorage.removeItem('token');
}
} catch (error) {
(document.querySelector('.auto-save-loader') as HTMLInputElement).style.display = 'none';
}
}
}
Summary and next steps
In this part of the series, I explained how to save the game data online using PlanetScale Cloud Platform and Netlify Serverless functions. We learned what serverless functions are, and how to use them with the database. Also, we created different serverless functions to:
Register a new user
Login a user
Save the high scores and coins
Fetch the high scores and coins and
Save and fetch the user's characters
What Next? Well, this is definitely going to be my last article in this series, but not going to be my last contribution to the project. There's still a lot that can be done on this project like:
Playing music and adding sound effects when the player collides with coins and obstacles
Adding more game design, instead of running inside a wooden cave, which can get boring at times, we could make the player run in a city, dessert, etc.
More collectibles, such as extra lives could also be added.
The project is hosted on GitHub and open for modifications. You're invited to either clone or fork it and customize it as you see fit. Don't forget, the sky is your starting point!
Also, don't forget to follow me on this website and my social media handles. I'm currently cooking some stuff with React, GraphQL, and MongoDB and you cannot afford to miss it.