Blog

Creating An Online Multiplayer Game with Firebase and GameMaker Studio 2 - Backend

Freeforge is an online multiplayer game I’m co-developing that is built around this framework

Freeforge is an online multiplayer game I’m co-developing that is built around this framework

Multiplayer games are notoriously hard to program. They require the use of several disciplines and quite possibly, several languages depending on whether you decide to program your server architecture in a different language than your client architecture. A lot of young game developers might have thought to themselves at the beginning of their game development careers:

I want to make a 3D massively online multiplayer game where you can fight any player you meet and blah blah blah.

~ You

Only to have failed embarrassingly when trying to find out how to connect multiple players together in one environment. Even if they managed to connect clients together, they might fail to create a reliable system that authenticates and manages users while also keeping track of clients positions and actions. Again, multiplayer games are notoriously hard to program.

In this post, I want to discuss how I’ve created a backend system for multiplayer games in GameMaker Studio 2 and Firebase. The type of multiplayer used here involves having clients host games and having a backend that keeps track of users, authenticates them and allowing clients to connect to other clients who are hosting games. The languages that I’ve used in this is GML and Typescript but you should be able to do this in the language of your choice.

This post is geared towards intermediate to advanced programmers with a bit of experience in node.js. There are several tutorials out there that can help you get started writing for node. This ain’t the one chief. Also note, these are not step by step instructions. As the title says, it’s an overview. It would be too time consuming to write a tutorial covering every single concept used in developing this. If there’s any interest in source code, leave a comment below and I’ll try make a public source implementation on GitHub. Without further ado;

Backend using Firebase

Image Courtesy of Google

Image Courtesy of Google

Firebase is a platform focused for mobile app developers. It has a number of tools that enable developers to create their own platforms for their systems. These include:

  • Authentication (Used for registering and authenticating users)

  • Firestore (A NoSQL database)

  • Storage (Used for storing files)

  • Cloud Functions (Used for creating backend functions)

  • and more!

Although these were developed with mobile in mind, you don’t need to have a mobile application to access these features especially for the architecture that we’re creating. For our project, we’ll be using Authentication, Firestore and Cloud Functions. Please note, that cloud functions are written in javascript or typescript (although the typescript is compiled into javascript) so a good understanding of at least javascript is a bonus. To continue, you should create a Firebase project and take note of your API key by selecting Web Setup once you login.

Firebase Project (Web Setup top right corner)

Firebase Project (Web Setup top right corner)

User Registration and Authentication

One of my favourite things about Firebase is that it can handle user login and authentication by providing a fully documented RESTful API. So let’s start with registering users. Looking at the documentation I just provided, we can see that in order to register a user, we have to send a HTTP Post request to the URL:

https://www.googleapis.com/identitytoolkit/v3/relyingparty/signupNewUser?key=[API_KEY]

Replacing [API_KEY] with the API key that you took note of earlier. In the header, we have to include the key value pair ‘Content-Type: application/json’ and in the payload, we supply the email, password, and a boolean value named returnSecureToken which should always be set to true. Here’s a GML script that sets the required post request:

User Registration Request in GameMaker

var url, header, payload, email, password;
email = argument0;
password = argument1;

url = "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyPassword?key=" + FIREBASE_APIKEY;
header = ds_map_create();
payload = ds_map_create();

header[? "Content-Type"] = "application/json";
payload[?"email"] = string(email);
payload[?"password"] = string(password);
payload[?"returnSecureToken"] = "true";
httpRequest = http_request(url, "POST", header, json_encode(payload));

//destroy payload and header maps
ds_map_destroy(header);
ds_map_destroy(payload);

In GameMaker, the results from http functions are returned in the Async - HTTP event. This is where we need the httpRequest variable we used above to get the returned value from the request. Looking at the documentation. We know that we expect from this request in the json that we recieve:

  • kind: always "identitytoolkit#SignupNewUserResponse".

  • idToken: A Firebase Auth ID token for the newly created user.

  • email: The email for the newly created user

  • refreshToken: A Firebase Auth refresh token for the newly created

  • expiresIn: The number of seconds in which the ID token expires.

  • localId: The uid of the newly created user.

In our Async - HTTP event, let’s add some code that will handle the response:

Registration Response Handling

if (async_load[? "id"] == httpRequest) {
  switch (async_load[? "status"]) {
    case 0:
        var result = async_load[? "result"], json = json_decode(result), target = json;
        //Handle errors
        if(target[? "error"] != undefined)
        {
            //Error handling code
            break;
        }
        switch(target[? "kind"])
        {
            case "identitytoolkit#SignupNewUserResponse":
                //Get response from signing up a new user
                //Store results in variables
                email = target[? "email"];
                idToken = target[? "idToken"];
                uID = target[? "localId"];
                break;
        }
    }
}

Looking at the authentication API, you should be able to figure out how to make a call to login a user and how to handle the variables that request returns.

You might’ve noticed that I did not care about the refreshToken and some other variables. First, let me explain how Firebase’s tokens work. You are given an idToken and a refreshToken. idTokens are short lived and only last an hour. A refresh token can be used to refresh the idToken once it’s expired. The idToken, is used to authenticate your users requests to your Firebase resources such as being able to read or write data in your database.

The thing is, we don’t need this for the type of architecture that I am proposing. If we use cloud functions, users can make requests to get certain data such as the servers which are currently available from the backend. So I threw away the refreshTokens. I figured it would also be annoying to reauthenticate users every hour. Instead, I decided on using session cookies that are provided by the firebase admin sdk.


Login Page from FreeForge

Login Page from FreeForge

Cloud Functions for Backend

Another feature I really love about Firebase is its cloud functions. Being able to run a distributed backend without having to host a server can allow small teams to cut down on costs heavily. As I mentioned above, I’ll be using Typescript for my cloud functions. Once you’ve set up your firebase functions project on your local system using Node Packet Manager (remember to select Typescript as language), we can start using express to deal with http requests from our users. Let’s see an express route that would return a session cookie to GameMaker (please note, I did not return it as a cookie as GameMaker’s file system is sandboxed. So I took it into my own hands to store the string).

Getting a Firebase Session Cookie

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as express from 'express';
import * as cors from 'cors';

admin.initializeApp({
    credential: admin.credential.cert({
        projectId: [PROJECT ID],
        privateKey: [PRIVATE KEY],
        clientEmail: [SERVICE ACCOUNT EMAIL],
    }),
    databaseURL: [DATABASE URL]
  });

const app = express();
const db = admin.firestore();
app.use(cors({ origin: true}));

//Here we give the user their session cookie once they've obtained an idToken
//This cookie can be used for access to systems
app.post('/getsession', function (req, res) {
    let idToken, expiresIn;
    switch (req.get('Content-Type')) {
        case 'application/json':
            idToken = req.body.idToken;
            expiresIn = 60 * 60 * 24 * 7 * 1000;
            admin.auth().verifyIdToken(idToken)
            .then(function(userUID){
                admin.auth().createSessionCookie(idToken, {expiresIn})
                .then(function(sessionCookie){
                    res.end(JSON.stringify({kind: 'GetSessionResponse',session: sessionCookie}));
                })
                .catch(function(){
                    res.status(401).send('UNAUTHORIZED REQUEST!');
                })
                console.log(userUID);
            })
            .catch(function(){
                res.send("UNAUTHENTICATED USER. PLEASE LOGIN");
            })
            break;
        default:
            res.send("'Content-Type' must be 'applications/json'");
            break;
    }
});

As you can see, this route takes a JSON payload that contains the users idToken. It then verifies the idToken and generates a session cookie if that idToken is valid. Once you deploy your firebase funnctions, your GameMaker game (or whatever solution you use for the client) should make a request to that route and store the cookie somewhere safe where it will be used in follow up requests to verify if the session is still valid. If we store the cookie with the userID in a file, when a user opens the application, we can read from this file and send this to the backend to verify whether an account has a valid session. This can allow users to stay logged in and play without having to type in their email and password every time they want to open our game.

Updating and Getting Server List

Earlier, I discussed keeping a server list. To do this, we need to keep note of a host’s IP address and port that they are using to host a game. When a player is trying to host a game, they can contact our backend that verifies their session before storing their details in our database. Here’s an example of how we can do that and also retrieve a list of servers from the database:

import * as functions from 'firebase-functions';
import * as admin from 'firebase-admin';
import * as express from 'express';
import * as cors from 'cors';

admin.initializeApp({
    credential: admin.credential.cert({
        projectId: [PROJECT ID],
        privateKey: [PRIVATE KEY],
        clientEmail: [SERVICE ACCOUNT EMAIL],
    }),
    databaseURL: [DATABASE URL]
  });

const app = express();
const db = admin.firestore();
app.use(cors({ origin: true}));

//When a user is trying to host a server, they can query the backend to set them to host. We store the address and port of the server under the document
//name of th userID meaning only one instance of the logged in user can host at a time.
app.post('/hostserver', function(req, res) {
    let sessionCookie, data;
    switch(req.get('Content-Type')){
        case 'application/json':
            sessionCookie = req.body.sessionCookie || '';
            admin.auth().verifySessionCookie(sessionCookie, true)
            .then(function(){
                data = {
                    address: req.body.address,
                    port: req.body.port,
                    lastOnline: new Date(),
                };
                db.collection('servers').doc(req.body.uID).get()
                  .then(doc => {
                    db.collection('servers').doc(req.body.uID).set(data)
                    .then(function(){
                        res.end(JSON.stringify({kind: 'PublicServerCreateResponse', data: data}))
                    })
                    .catch(function(){
                        res.send("Couldn't Initialize Public Server");
                        console.log("Public Server Initialization Failed");
                    })
                    if (!doc.exists) {
                      console.log('New server');
                    } else {
                      console.log('Update server', doc.data());
                    }
                  })
                  .catch(err => {
                    console.log('Error getting document', err);
                  });
            })
            .catch(function(){
                res.status(400).send("INVALID SESSION. PLEASE LOGIN AGAIN.")
            });
            break;
            default:
            res.send("'Content-Type' MUST BE 'applications/json'");
            break;
    }
});

app.post('/getservers', function(req, res) {
   let sessionCookie, date, arrayResults;
   switch(req.get('Content-Type')) {
       case 'application/json':
       sessionCookie = req.body.sessionCookie;
       admin.auth().verifySessionCookie(sessionCookie)
       .then(function(){ 
            date = new Date();
            db.collection('servers').where('lastOnline', '>', new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds()-10)).get()
            .then(querySnapshot => {
                if (querySnapshot.empty) {
                    res.send("NO SERVERS AVAILABLE");
                } else {
                    arrayResults = new Array();
                    querySnapshot.forEach(documentSnapshot=> {
                        arrayResults.push(documentSnapshot.data());
                    });
                    res.end(JSON.stringify({kind: 'PublicServerSearchResponse',servers: arrayResults}));
                }
            })
            .catch(err => {
                res.send("ERROR GETTING SERVERS")
                console.log('Error getting document', err);
            });
       })
       .catch(function(){
            res.status(400).send("INVALID SESSION. PLEASE LOGIN AGAIN.")
       });
            break;
        default:
        res.send("'Content-Type' MUST BE 'applications/json'");
            break;
   } 
});

As you can see, when hosting a server, the user has their session cookie verified first before storing the supplied IP address and port from the JSON payload in a document under ‘servers’ with the date and time the server was created named after the hosting users uID. This can make it easy to query for a user in the future. We can make a host request every few seconds in order to keep the server list updated. You can see from the ‘/getservers’ route that we only return servers that have been alive in the last 5 seconds so that servers which are inactive are kept away from the user.

Conclusion

And that’s pretty much it. Of course, you need to develop your own methods in GameMaker to make requests to your backend and obviously, you need to create a login system using the Firebase Authentication RESTful API but as long as you follow the basics provided here, you should be able to set this up in no time.

Other than that, you have to determine how a client connects (TCP, UDP, Custom UDP, Hybrid) to a host and how to do movement and actions. This is a topic on it’s own and includes things such as Server Authoritative networking and Client Side prediction and correction. If you’re interested in knowing more about how to do these things, drop a comment below and I’ll gauge the interest in providing an explanation and/or source code on GitHub.

P.S, please keep note of FreeForge’s Patreon Page, we should have some gameplay videos and announcements soon :)

Thank you for tuning in!