32
loading...
This website collects cookies to deliver better user experience
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",
...
},
// 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"
},
server
directory run npm run start
, your server should be successfully running in port:8080.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.
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");
};
}
)
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];
});
});
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.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);
...
});
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.
”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.Main
. Luckily in Main
we have passed a config object into the IonPhaser component.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;
The registry is essentially the apps global state which you can use to store a variety of global variables between Scenes.
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();
};
...
}
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;
})
This data will be used later to assign the correct Aavegotchi data to the leaderboard.
// 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});
}
...
}
...
}
}
"gameStarted"
event, we want to fire this upon the scenes creation."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."gameOver"
event that corresponds to the users client side score.// 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);
})
...
});
...
console.log
for the game starting and ending.// 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();
})
...
});
timeStarted
. Using this we should be able to calculate an approximation of what the users score should be.addPipeRow()
is called every 2 seconds. Therefore, the time between going through each pipe should also be 2 seconds.// 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);
}
})
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).
// 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);
}
})
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.
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.
“service-account.json”
and save it to the root of the server directory.service-account.json
file:// .gitignore
service-account.json
firebase-admin
within your server directory, so open up a new terminal, and in your server directory run:npm install firebase-admin
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)
});
Start in production mode
and select the location you want your database to live.firestore
like so:// server/server.ts
const db = admin.firestore();
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"
}
}
}
tokenId
..set()
method which allows you to either post the data if it doesn't exist, or override it if it does.// 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);
}
})
firebaseConfig
variable:/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
The REACT_APP is important to allow React to access the the variables.
app
directory, run:npm install firebase
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);
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read: if true;
allow write: if false;
}
}
}
service-account
key that is only available to the projects admin.You may need to restart the app in the terminal so the new .env variables can take effect.
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);
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)
);
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.setMyHighscores
that assigns the value of Mutable Ref Object.const myHighscoresRef = useRef(highscores);
const setMyHighscores = (data: Array<HighScore>) => {
myHighscoresRef.current = data;
setHighscores(data);
};
.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));
}
});
});
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.current
that is mutable.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.
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.
32