DEV Community

Cover image for Building a full-stack Aavegotchi minigame - Part 2: Server + Leaderboard
Caleb Coyote
Caleb Coyote

Posted on • Edited on

Building a full-stack Aavegotchi minigame - Part 2: Server + Leaderboard

In Part 1 of the tutorial series, we created an Aavegotchi flappigotchi clone using Phaser3. Now let’s say we want to make our game competitive so that each player can compete for the top spot of the leaderboard to earn Aavegotchi rewards.

If we was to do the score submission on the client side, then a malicious player could open up the dev tools, intercept the network request and submit whatever score they like. This is a big no no.

Hackers hacking

To prevent this we need some server side logic that can verify whether a score is legitimate or not, and if so, store the score in the database. For that to work, the game needs to have an idea of the gameplay session that took place. That's where WebSockets come in!

 

End result

By the end of Part 2 you will be equipped with the knowledge you need to set up a server that can verify and submit a user's score to a database.

We will be building off of the Flappigotchi game from part 1, and will write our server-side logic in Express + NodeJs, and use Socket.io to handle our WebSocket connection.

We will also be learning how to use Google's Firestore to store and view leaderboard data. However feel free to use a database preference of your choice, the concept remains the same.

 

Step 1) Ensure game is working

 

Optional: Starting fresh

If you are starting here, make sure to clone the Part 1 repo to get up to speed.

If on Windows, ensure your package.json scripts are correct:

// app/package.json

  "scripts": {
    ...
-   "start:offchain": "REACT_APP_OFFCHAIN=true react-scripts start",
+   "start:offchain": "set REACT_APP_OFFCHAIN=true && react-scripts start",
    ...
  },
Enter fullscreen mode Exit fullscreen mode
// server/package.json

  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
-   "start:prod": "NODE_ENV=production nodemon server.ts",
+   "start:prod": "set NODE_ENV=production && nodemon server.ts",
-   "start": "NODE_ENV=development nodemon server.ts"
+   "start": "set NODE_ENV=development && nodemon server.ts"
  },
Enter fullscreen mode Exit fullscreen mode

Open up a terminal, and within the server directory run npm run start, your server should be successfully running in port:8080.

In another terminal, open the app directory run npm run start (or npm run start:offchain if you want to run the app without a Web3 connection or you don't own any Aavegotchi's), to run your app on http://localhost:3000/.

If you have only just downloaded the repo, ensure you have installed the dependencies by running npm install in both the server and app directories.

Home screen

 

Server connection events

When you start the game, in the terminal in which you are running the server, you should receive a message upon connecting and disconnecting from the game.

Connection logs in the terminal

In the template, all the Phaser code is wrapped by a React component called Main in app/src/game/main.tsx. When this component renders, a useEffect React hook handles the connection to the WebSocket using the socket.io-client, and then when the component unmounts it fires an event called “handleDisconnect”.

// app/src/game/main.tsx

useEffect(() => {
  if (usersAavegotchis && selectedAavegotchiId) {
    // Socket is called here so we can take advantage of the useEffect hook to disconnect upon leaving the game screen
    const socket = io(process.env.REACT_APP_SERVER_PORT || 'http://localhost:8080');

    ...

    return () => {
      socket.emit("handleDisconnect");
    };
  }
)
Enter fullscreen mode Exit fullscreen mode

If you open server/server.ts you can see the order of logic on the server's side.

// server/server.ts

io.on('connection', function (socket: Socket) {
    const userId = socket.id;

    console.log('A user connected: ' + userId);
    connectedGotchis[userId] = {id: userId};

    socket.on('handleDisconnect', () => {
      socket.disconnect();
    })

    socket.on('setGotchiData', (gotchi) => {
      connectedGotchis[userId].gotchi = gotchi;
    })

    socket.on('disconnect', function () {
      console.log('A user disconnected: ' + userId);
      delete connectedGotchis[userId];
    });
});

Enter fullscreen mode Exit fullscreen mode

When the user connects, it assigns the randomly generated id to a userId constant, logs it to the console, and then stores it in as a key-value pair in an object called connectedGotchis. Every concurrent connection will be stored within the same connectedGotchis instance.

To prove this, add a console.log(connectedGotchis) after the key-value pair has been assigned in the socket connection:

io.on('connection', function (socket: Socket) {
    const userId = socket.id;

    console.log('A user connected: ' + userId);

    connectedGotchis[userId] = {id: userId};
    console.log(connectedGotchis);


    ...
});
Enter fullscreen mode Exit fullscreen mode

Then open the game in another tab and try and start both games at the same time. You should see the 2 connections.

Concurrent connection log

This also touches on how you can add a simple form of multiplayer to your games. Every connected user will have access to the same server instance and therefore will be able to send each other events through the server.

When the app fires the ”handleDisconnect” event, the server listens for it and fires socket.disconnect() which in turn fires the "disconnect" event. This uses the userId to delete the correct key-value pair in the connectedGotchis object.

 

Step 2) Handling in game events

 

To handle in game events, we first need a way to access to the Socket we initialised within Main. Luckily in Main we have passed a config object into the IonPhaser component.

In this config object, we used the callbacks property to set some items in the games registry at the start of the games bootsequence.

// app/src/game/main.tsx

...

const Main = () => {
  ...

  const startGame = async (socket: Socket, selectedGotchi: AavegotchiObject) => {
    ...

    setConfig({
      type: Phaser.AUTO,
      physics: {
        default: "arcade",
        arcade: {
          gravity: { y: 0 },
          debug: process.env.NODE_ENV === "development",
        },
      },
      scale: {
        mode: Phaser.Scale.NONE,
        width,
        height,
      },
      scene: Scenes,
      fps: {
        target: 60,
      },
      callbacks: {
        preBoot: (game) => {
          // Makes sure the game doesnt create another game on rerender
          setInitialised(false);
          game.registry.merge({
            selectedGotchi,
            socket
          });
        },
      },
    });
  }

  ...

  return <IonPhaser initialize={initialised} game={config} id="phaser-app" />;
};

export default Main;
Enter fullscreen mode Exit fullscreen mode

The registry is essentially the apps global state which you can use to store a variety of global variables between Scenes.

In app/game/scenes/boot-scene.ts you can see how we then access this socket instance to check if we are connected to the server, and then fire own custom event within the handleConnection function:

// app/game/scenes/boot-scene

...

export class BootScene extends Phaser.Scene {
  ...

  public preload = (): void => {
    ...

    // Checks connection to the server
    this.socket = this.game.registry.values.socket;
    !this.socket?.connected
      ? this.socket?.on("connect", () => {
          this.handleConnection();
        })
      : this.handleConnection();

    ...
  };

  /**
   * Submits gotchi data to the server and attempts to start game
   */
  private handleConnection = () => {
    const gotchi = this.game.registry.values.selectedGotchi as AavegotchiObject;
    this.connected = true;
    this.socket?.emit("setGotchiData", {
      name: gotchi.name,
      tokenId: gotchi.id,
    });

    this.startGame();
  };

  ...
}

Enter fullscreen mode Exit fullscreen mode

This event sends information to our server about the selected Aavegotchi. In server.ts you can see that we use this data to attach a gotchi property to our key-value pairing.

// server/server.ts

socket.on('setGotchiData', (gotchi: Gotchi) => {
  connectedGotchis[userId].gotchi = gotchi;
})

Enter fullscreen mode Exit fullscreen mode

This data will be used later to assign the correct Aavegotchi data to the leaderboard.

Now that we have seen an example of how to send events to the server, let's add our own custom events.

 

Session time validation

As our game is simple, for our server validation, we are going to create one simple rule to check if a users score is legitimate or not. We will use the duration of the playtime to determine whether or not a score is possible.

For this, we need the game to send 2 events, one for when the game starts, and one for when the game ends.

// app/game-scene.ts
...
import { Player, Pipe, ScoreZone } from 'game/objects';
import { Socket } from "socket.io-client";

...
export class GameScene extends Phaser.Scene {
  private socket?: Socket;
  private player?: Player;
  ...
  private scoreText?: Phaser.GameObjects.Text;

  private isGameOver = false;

  ...

  public create(): void {
    this.socket = this.game.registry.values.socket;
    this.socket?.emit('gameStarted');

    // Add layout
    ...
  }

  ...

  public update(): void {
    if (this.player && !this.player?.getDead()) {
      ...
    } else { 
      if (!this.isGameOver) {
        this.isGameOver = true;
        this.socket?.emit('gameOver', {score: this.score});
      }

      ...
    }

    ...
  }
}


Enter fullscreen mode Exit fullscreen mode

For our "gameStarted" event, we want to fire this upon the scenes creation.

We want to send our "gameOver" event once upon the players death, for this we added a isGameOver state so we can assure the event doesn't trigger multiple times.

We have also sent some data along with the "gameOver" event that corresponds to the users client side score.

 

Step 3) Server side logic

 

Now that our app is sending the events, we need to listen for them on the server.

// server/server.ts

...

io.on('connection', function (socket: Socket) {
  ...

  socket.on('gameStarted', () => {
    console.log('Game started: ', userId);
  })

  socket.on('gameOver', async ({ score }: { score: number }) => {
    console.log('Game over: ', userId);
    console.log('Score: ', score);
  }) 

  ...
});

...

Enter fullscreen mode Exit fullscreen mode

If you was to play the game now, you should see in your server's terminal the console.log for the game starting and ending.

On gameStart we want to take a snapshot of the time in which the server receives the gameStarted event:

// server/server.ts

io.on('connection', function (socket: Socket) {
  const userId = socket.id;
  let timeStarted: Date;

  ...

  socket.on('gameStarted', () => {
    console.log('Game started: ', userId);
    timeStarted = new Date();
  })

  ...
});
Enter fullscreen mode Exit fullscreen mode

Then on game over, we can get the difference between the time now and the timeStarted. Using this we should be able to calculate an approximation of what the users score should be.

When building the gameplay, we made it so addPipeRow() is called every 2 seconds. Therefore, the time between going through each pipe should also be 2 seconds.

At the start of the game, there is also a little bit of extra distance before reaching the first pipe row. You can get the time to travel this distance in a hacky way by console logging the time difference going through the first pipe row and starting, and then subtracting the 2 seconds. I got this time to be roughly 0.2 seconds.

Therefore, if we was to calculate the score, using only the time value, the equation will look like:

Score equation

We also know that the score should be an integer as the score increases at intervals of 1. So if the server calculates a score of 2.8 for example, we should round the number down to 2 as that means the Player died somewhere between pipe 2 and 3.

What this looks like in code is the following:

// server/server.ts

socket.on('gameOver', async ({ score }:{ score: number }) => {
   console.log('Game over: ', userId);
   console.log('Score: ', score);

   const now = new Date();
   const dt = Math.round((now.getTime() - timeStarted.getTime())) / 1000;
   const timeBetweenPipes = 2;
   const startDelay = 0.2;
   const serverScore = dt / timeBetweenPipes - startDelay;

   if (score === Math.floor(serverScore)) {
     console.log("Submit score: ", score);
   } else {
     console.log("Cheater: ", score, serverScore);
   }
})
Enter fullscreen mode Exit fullscreen mode

Now if you play the game a couple of times, and look at the server terminal, you can find out whether you are cheating or not.

 

Error margins

What you should find is some of the time the server is not accurate and is calling you out for being a cheater, this becomes more apparent the longer your run.

This is because the numbers we used to calculate the score are not 100% accurate. They were approximations. In reality there are numerous ways in which the server may be a bit out of sync, the client may have some minor spikes in their frame rate, or the latency between the client and server may be a bit slow.

Because of this we need to take into account a margin of error into our servers calculations.

This tutorial wont be going into how to calculate error margins (if you want to look into that there are plenty of maths resources and videos on the web).

Just take my word, that for the time between each pipe, there is approximately an error range of about 0.03s either direction.

This means, that the time between each pipe, is somewhere in the range of 1.97s and 2.03 seconds. Therefore, the higher the score, the higher the error of margin.

Thus, what we need to calculate is the lower and upper bound for our score, and see if the score sent from the client fits within their range:

// server/server.ts

socket.on('gameOver', async ({ score }:{ score: number }) => {
  console.log('Game over: ', userId);
  console.log('Score: ', score);

  const now = new Date();
  const dt = Math.round((now.getTime() - timeStarted.getTime())) / 1000;
  const timeBetweenPipes = 2;
  const startDelay = 0.2;
  const serverScore = dt / timeBetweenPipes - startDelay;
  const errorRange = 0.03;

  const lowerBound = serverScore * (1 - errorRange);
  const upperBound = serverScore * (1 + errorRange); 

   if (score >= Math.floor(lowerBound) && score <= upperBound) {
      console.log("Submit score: ", score, lowerBound, upperBound);
   } else {
     console.log("Cheater: ", score, serverScore);
   }
})
Enter fullscreen mode Exit fullscreen mode

If you notice that a legitimate games score seems to be not within the server scores bounds some of the time, then you can always try increasing the errorRange slightly.

Fantastic! Now we have our server verifying whether a score is legitimate or not, we can send it to the server.

Of course, using this server side logic doesn't prevent our game from being completely hack proof. If a nefarious player figures out the server side logic, they could send a network request for ‘gameStart’ and then wait a set amount of time before sending the ‘gameOver’ event with the correct score.

They could also on the client side, edit the code so that collisions with the pipes don't end the game, and just easy-mode their way to the top.

It's up to you to think of the various ways in which the user could exploit the game, and write some more server events to prevent them by mirroring a real game session as much as possible. Flappigotchi being a simple game, there is only so much you can do, however one potential task you could do for homework is write an event that triggers every time the player goes through 5 pipe rows. Then on the server end, if you are not receiving this event approximately every 10 seconds then you know the user must be doing something dastardly.

 

Step 4) Setting up Firebase (Optional)

 

In this part of the tutorial I will guide you through the steps of setting up a Firestore database. If you want to use a different database, feel free to skip ahead. For more detailed explanations of the setup, you can follow the official firebase steps found here.

Assuming you have already got a Google account signed up to Firebase, go to your firebase console and create a new project.

Firebase console

It will guide you through 2 or 3 steps:

1) Name your project: This can be whatever you want to use to identify the project
2) Enable Google Analytics: Not used in the scope of this tutorial, but may be useful if you want to take this project further.

Once that is done your project will be created and you will be presented with the project dashboard.

To have your server be able to connect with your Firebase, you need to generate a service account key.

To do this, in your console, click on the cog next to Project Overview and select Project Settings.

Then under the Service Accounts tab, click on the Generate New Private Key button.

Service account directions

It’s very important that you have this json file only be accessible to you and your team. However, it also needs to be accessible by your code.

For this tutorial, call the newly downloaded file “service-account.json” and save it to the root of the server directory.

To ensure that this key doesn't upload to Github, in the ‘.gitignore’ file add in the name of the service-account.json file:

// .gitignore

service-account.json
Enter fullscreen mode Exit fullscreen mode

Next, we need to install firebase-admin within your server directory, so open up a new terminal, and in your server directory run:

npm install firebase-admin
Enter fullscreen mode Exit fullscreen mode

Once installed, you should have everything you need to connect your server to firebase. Simply import in both firebase-admin and your service-account key and initialise the app:

// server/server.ts

const serviceAccount = require('./service-account.json');
const admin = require('firebase-admin');
admin.initializeApp({
 credential: admin.credential.cert(serviceAccount)
});

Enter fullscreen mode Exit fullscreen mode

To set up a Firestore database, go back to your firebase console. Click on Firestore Database in the side menu, and then click on the Create Database button.

Firestore database

Opt for Start in production mode and select the location you want your database to live.

You should now have access to your firestore like so:

// server/server.ts

const db = admin.firestore();

Enter fullscreen mode Exit fullscreen mode

 

Step 5) Sending data to database

 

Now that we have access to our database, lets create a function in server.ts called SubmitScore():

// server/server.ts

const db = admin.firestore();

interface ScoreSubmission {
  tokenId: string,
  score: number,
  name: string
}

const submitScore = async ({tokenId, score, name}: ScoreSubmission) => {
 const collection = db.collection('test');
 const ref = collection.doc(tokenId);
 const doc = await ref.get().catch(err => {return {status: 400, error: err}});

 if ('error' in doc) return doc;

 if (!doc.exists || doc.data().score < score) {
   try {
     await ref.set({
       tokenId,
       name,
       score
     });
     return {
       status: 200,
       error: undefined
     }
   } catch (err) {
     return {
       status: 400,
       error: err
     };
   }
 } else {
   return {
     status: 400,
     error: "Score not larger than original"
   }
 }
}

Enter fullscreen mode Exit fullscreen mode

The beauty of Firestore is it is very flexible and simple to use. The collection is essentially the database which we want to send data to. It's important to use a different collection for your highscores in development mode to separate your scores whilst testing from the live leaderboard. Therefore we will call our collection “test”. We then label our data by the Aavegotchi's tokenId.

Before we submit the data to the database, we want to add a simple logic gate. Essentially we don't want to submit data to the Highscore database if the score being submitted isn’t higher than the score that currently exists for the given Aavegotchi. Therefore we have 2 checks

1) If data doesn't exist, push the given score to the database
2) If data exists, but the score is less than the new score, override the data with a new score.

Firestore has a useful .set() method which allows you to either post the data if it doesn't exist, or override it if it does.

Now all we have to do is call this method within the ‘gameOver’ event:

// server/server.ts

socket.on('gameOver', async ({ score }:{ score: number }) => {
    ...
  if (score >= Math.floor(lowerBound) && score <= upperBound) {
    const highscoreData = {
      score,
      name: connectedGotchis[userId].gotchi.name,
      tokenId: connectedGotchis[userId].gotchi.tokenId,
    }
    console.log("Submit score: ", highscoreData);

    try {
      const res = await submitScore(highscoreData);
      if (res.status !== 200) throw res.error;
      console.log("Successfully updated database");
    } catch (err) {
      console.log(err);
    }
  } else {
    console.log("Cheater: ", score, lowerBound, upperBound);
  }
})
Enter fullscreen mode Exit fullscreen mode

Now when you play the game, whatever score you get should be posted to the database.

Super high score

 

Step 6) Setting up Firebase App side

 

Before we can retrieve the data, we need to first get some more Firebase API keys so our app can view data from Firebase. To get this, go to your on the Project Overview page and click on the web button.

Add firebase to web

Then register your app with your name of choice.

Register app

(There is no need to setup Firebase hosting at this point)

You will be presented with a chunk of code. The only bit that is relevant to us are the keys within the firebaseConfig variable:

Firebase config

Create a file in the /app directory called .env.development. Here paste in your keys and convert it to the following:

// app/.env.development

REACT_APP_FIREBASE_APIKEY=”API_KEY_HERE"
REACT_APP_FIREBASE_AUTHDOMAIN="AUTHDOMAIN_HERE"
REACT_APP_FIREBASE_PROJECTID="PROJECT_ID_HERE"
REACT_APP_FIREBASE_STORAGEBUCKET="STORAGE_BUCKET_HERE"
REACT_APP_FIREBASE_MESSAGINGSENDERID="STORAGE_BUCKET_HERE"
REACT_APP_FIREBASE_APPID="APP_ID_HERE

Enter fullscreen mode Exit fullscreen mode

The REACT_APP is important to allow React to access the the variables.

Now inside the app directory, run:

npm install firebase
Enter fullscreen mode Exit fullscreen mode

 

Step 7) Displaying Highscore data

 

We now need to be able to receive and display the Highscore data on the app. In the template, there is already a Context Provider set up to handle database logic within app/src/server-store/index.tsx. At the moment, it only stores data to the users localStorage, so the page can be rewritten to the following:

// app/src/server-store/index.tsx

import React, {
  createContext, useContext, useEffect, useState,
 } from 'react';
 import { HighScore } from 'types';
 import fb from 'firebase';

 const firebaseConfig = {
  apiKey: process.env.REACT_APP_FIREBASE_APIKEY,
  authDomain: process.env.REACT_APP_FIREBASE_AUTHDOMAIN,
  databaseURL: process.env.REACT_APP_FIREBASE_DATABASEURL,
  projectId: process.env.REACT_APP_FIREBASE_PROJECTID,
  storageBucket: process.env.REACT_APP_FIREBASE_STORAGEBUCKET,
  messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGINGSENDERID,
  appId: process.env.REACT_APP_FIREBASE_APPID,
  measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENTID,
 };

 interface IServerContext {
  highscores?: Array<HighScore>;
 }

 export const ServerContext = createContext<IServerContext>({});

 export const ServerProvider = ({
  children,
 }: {
  children: React.ReactNode;
 }) => {
  const [highscores, setHighscores] = useState<Array<HighScore>>();
  const [firebase, setFirebase] = useState<fb.app.App>();

  const sortByScore = (a: HighScore, b: HighScore) => b.score - a.score;

  const converter = {
    toFirestore: (data: HighScore) => data,
    fromFirestore: (snap: fb.firestore.QueryDocumentSnapshot) =>
      snap.data() as HighScore,
  };

  useEffect(() => {
    const getHighscores = async (_firebase: fb.app.App) => {
      const db = _firebase.firestore();
      const highscoreRef = db
        .collection("test")
        .withConverter(converter);
      const snapshot = await highscoreRef.get();

      const highscoreResults: Array<HighScore> = [];
      snapshot.forEach((doc) => highscoreResults.push(doc.data()));
      setHighscores(highscoreResults.sort(sortByScore));
    };

    if (!firebase) {
      const firebaseInit = fb.initializeApp(firebaseConfig);
      setFirebase(firebaseInit);
      getHighscores(firebaseInit);
    }
  }, [firebase]);

  return (
    <ServerContext.Provider
      value={{
        highscores,
      }}
    >
      {children}
    </ServerContext.Provider>
  );
 };

 export const useServer = () => useContext(ServerContext);

Enter fullscreen mode Exit fullscreen mode

This will initiate firebase on initial render of the app, and then fetch all the highscore's from the collection set in your .env file.

The convertor is used so we can assign types to the data received from Firebase.

The last thing we need to do before we can view the data from Firebase is give our app viewing permissions.

To do this, go to your Firebase console, navigate to the Firestore Database, then click on the rules tab:

Firestore rules

As we don’t care if a 3rd party wants to view our Highscore data, we set our rules as:

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read: if true;
      allow write: if false;
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

This will allow anyone to read the data, but writing permissions is not allowed. Our server has access to write as it has the service-account key that is only available to the projects admin.

The template has already hooked up the Context Provider to the apps UI, so now by running the app you should see your scores on the leaderboard.

Minigame leaderboard

You may need to restart the app in the terminal so the new .env variables can take effect.

If you was to beat your score now, you may notice the scores in the leaderboard do not update. If you refresh, you should see the database is working as intended. The problem is, we are only updating the apps state on initial load of the app. Therefore, we need to set up a listener, so when one of the users Aavegotchi's scores update, the apps state updates with it.

Thankfully Firebase has an eventListener just for this situation called onSnapshot. For this to work, we need to first have access to the users Aavegotchi's, therefore we need to write a new useEffect that initiates a snapshot listener when the users Aavegotchi's are updated. We need to to also ensure that this happens after the firebase has initiated.

// app/src/server-store/index.tsx

import React, {
  createContext,
  useContext,
  useEffect,
  useState,
  useRef,
} from "react";
import { HighScore, AavegotchiObject } from "types";
import { useWeb3 } from "web3/context";
import fb from "firebase";

...

export const ServerProvider = ({ children }: { children: React.ReactNode }) => {
  const {
    state: { usersAavegotchis },
  } = useWeb3();
  const [highscores, setHighscores] = useState<Array<HighScore>>();
  const [firebase, setFirebase] = useState<fb.app.App>();
  const [initiated, setInitiated] = useState(false);

  const sortByScore = (a: HighScore, b: HighScore) => b.score - a.score;

  const myHighscoresRef = useRef(highscores);

  const setMyHighscores = (data: Array<HighScore>) => {
    myHighscoresRef.current = data;
    setHighscores(data);
  };

  const converter = {
    toFirestore: (data: HighScore) => data,
    fromFirestore: (snap: fb.firestore.QueryDocumentSnapshot) =>
      snap.data() as HighScore,
  };

  const snapshotListener = (
    database: fb.firestore.Firestore,
    gotchis: Array<AavegotchiObject>
  ) => {
    return database
      .collection("test")
      .withConverter(converter)
      .where(
        "tokenId",
        "in",
        gotchis.map((gotchi) => gotchi.id)
      )
      .onSnapshot((snapshot) => {
        snapshot.docChanges().forEach((change) => {
          const changedItem = change.doc.data();
          const newHighscores = myHighscoresRef.current
            ? [...myHighscoresRef.current]
            : [];
          const itemIndex = newHighscores.findIndex(
            (item) => item.tokenId === changedItem.tokenId
          );
          if (itemIndex >= 0) {
            newHighscores[itemIndex] = changedItem;
            setMyHighscores(newHighscores.sort(sortByScore));
          } else {
            setMyHighscores([...newHighscores, changedItem].sort(sortByScore));
          }
        });
      });
  };

  useEffect(() => {
    if (usersAavegotchis && usersAavegotchis.length > 0 && firebase && initiated) {
      const db = firebase.firestore();
      const gotchiSetArray = [];
      for (let i = 0; i < usersAavegotchis.length; i += 10) {
        gotchiSetArray.push(usersAavegotchis.slice(i, i + 10));
      }
      const listenerArray = gotchiSetArray.map((gotchiArray) =>
        snapshotListener(db, gotchiArray)
      );

      return () => {
        listenerArray.forEach((listener) => listener());
      };
    }
  }, [usersAavegotchis, firebase]);

  useEffect(() => {
    const getHighscores = async (_firebase: fb.app.App) => {
      ...

      setMyHighscores(highscoreResults.sort(sortByScore));
      setInitiated(true);
    };

    ...
  }, [firebase]);

  ...
};

export const useServer = () => useContext(ServerContext);

Enter fullscreen mode Exit fullscreen mode

There are 2 strange things worth noting about this implementation that may seem confusing at first read.

One is the setting up of multiple listeners in sets of 10.

const gotchiSetArray = [];
for (let i = 0; i < usersAavegotchis.length; i += 10) {
  gotchiSetArray.push(usersAaveGotchis.slice(i, i + 10));
}
const listenerArray = gotchiSetArray.map((gotchiArray) =>
  snapshotListener(db, gotchiArray)
);
Enter fullscreen mode Exit fullscreen mode

This is because the onSnapshot can only listen out for a maximum of 10 documents, and there are a bunch of Aavegotchi maxis with a lot more than 10 Aavegotchi's that we need to account for. Therefore we split their Aavegotchi's into groups of 10 so we can set up a listener for all of them.

The second thing is instead of changing the state directly, we have created a new function called setMyHighscores that assigns the value of Mutable Ref Object.

const myHighscoresRef = useRef(highscores);

const setMyHighscores = (data: Array<HighScore>) => {
  myHighscoresRef.current = data;
  setHighscores(data);
};
Enter fullscreen mode Exit fullscreen mode

In the snapshot we have then used the refs value to retrieve the highscore data instead of the state.

.onSnapshot((snapshot) => {
  snapshot.docChanges().forEach((change) => {
    const changedItem = change.doc.data();
    const newHighscores = myHighscoresRef.current
      ? [...myHighscoresRef.current]
      : [];
    const itemIndex = newHighscores.findIndex(
      (item) => item.tokenId === changedItem.tokenId
    );
    if (itemIndex >= 0) {
      newHighscores[itemIndex] = changedItem;
      setMyHighscores(newHighscores.sort(sortByScore));
    } else {
      setMyHighscores([...newHighscores, changedItem].sort(sortByScore));
    }
  });
});
Enter fullscreen mode Exit fullscreen mode

This is necessary as the snapshot listener is assigned on the initial load of the app, therefore the onSnapshot callback uses the highscores state value at initiation as its value, even if it changes later. This leads to confusing instances, where getting a highscore on one of your Aavegotchis, then switching to another and getting a highscore, the second Aavegotchis score will override the first. This only affects the UI of course, but it can lead to panic for your users.

Therefore by using Reacts useRef, we can create a constant that is the same on every render, but contains a property called current that is mutable.

Therefore, when the callback triggers, the reference for the highscores data remains the same, so the code can then access it and retrieve the current value.

If you didn't understand all of that don't worry. I don't think many people do, nor is it critical information for the development of minigames. Just thought it would be interesting to tell you none the less.

 

Conclusion

 

Congratz, you have concluded Part 2 of the tutorial! If you play the game now, you should have a fully functioning app that is worthy of the Aavegotchi Aarcade.

you

In this lesson you have learnt how to implement basic server side validation, as well as how to read and write data on Firebase. All that is left to do is deploy both sides of your applications to the world wide web so people can play your game with their own Aavegotchis!

 

The end result for the code can be found here. However, please bear in mind the Firebase infrastructure is necessary to have a working leaderboard.

If you have any questions about Aavegotchi or want to work with others to build Aavegotchi minigames, then join the Aavegotchi discord community where you can chat and collaborate with other Aavegotchi Aarchitects!

Make sure to follow me @ccoyotedev or @gotchidevs on Twitter for updates on future tutorials.

If you own an Aavegotchi, you can play the end result of this tutorial series at flappigotchi.com.

Top comments (0)