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

Building an Endless Runner Game with Three.js, Mixamo, Vite, and PlanetScale (Part Five)

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:

  1. Initial Setup

  2. The running scene

  3. Main Menu Scene

  4. The Character selection scene

  5. Saving The Game Data Online

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, select Nodejs on the dropdown in front of the connect 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 loadLogoutScreenmethod.

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.