DEV Community

Cover image for Building a realtime multiplayer browser game in less than a day - Part 3/4
Srushtika Neelakantam for Ably

Posted on β€’ Edited on

10 1

Building a realtime multiplayer browser game in less than a day - Part 3/4

Hello, it's me again πŸ‘‹πŸ½

Welcome to Part 3 of this article series where we are looking at the step by step implementation of a realtime multiplayer game of Space Invaders with Phaser3 and Ably Realtime.

In the previous article, we learned all about networking for realtime multiplayer games and also the Pub/Sub messaging pattern. We then saw the design and channel layout for our game.


Here's the full index of all the articles in this series for context:


In this article, we'll start writing the server-side code to implement Pub/Sub in our application by following the client-server strategy to maintain synchronization between all the players.

In this article, we'll start writing the server-side code to implement Pub/Sub in our application by following the client-server strategy to maintain synchronization between all the players.

Before we get started, you will need an Ably API key to authenticate with Ably. If you are not already signed up, you should sign up now for a free Ably account. Once you have an Ably account:

  • Log into your app dashboard
  • Under "Your apps", click on the app you wish to use for this tutorial, or create a new one with the "Create New App" button
  • Click on the "API Keys" tab
  • Copy the secret "API Key" value from your root key and store it so that you can use it later in this tutorial

Until now, we worked on the index.html and script.js files. Let's go ahead and create a new file and call it server.js. This is where we'll write our server-side code in NodeJS.

Our game server is responsible for three main things:

  • Authenticate clients and assign to them a random and unique client ID so they can use the Ably Realtime service via the Token Auth strategy.
  • Serve as a single source of game-state truth and constantly publish the latest state to all the players
  • Manage and update the velocity and thus determine the position of the ship using a separate server-side Physics engine.

Let's get into each of these.

Using the p2 Physics library via NPM

If you remember, we discussed in the first article that Phaser comes with its own physics engine, which is why we didn't have to use another third-party library to implement physics on the client side. However, if the server needs to be able to update the velocity of the ship and compute it's position at any given time accordingly, then we'd need a physics engine on the server side as well. As Phaser is a graphics rendering library and not a standalone physics engine, it's not ideal to be used on the server side. We'll instead use another server-side physics engine called p2.js.

Let's start writing some server-side code by requiring a few NPM libraries and declaring some variables that we'll use later:

const envConfig = require("dotenv").config();
const express = require("express");
const Ably = require("ably");
const p2 = require("p2");
const app = express();
const ABLY_API_KEY = process.env.ABLY_API_KEY;
const CANVAS_HEIGHT = 750;
const CANVAS_WIDTH = 1400;
const SHIP_PLATFORM = 718;
const PLAYER_VERTICAL_INCREMENT = 20;
const PLAYER_VERTICAL_MOVEMENT_UPDATE_INTERVAL = 1000;
const PLAYER_SCORE_INCREMENT = 5;
const P2_WORLD_TIME_STEP = 1 / 16;
const MIN_PLAYERS_TO_START_GAME = 3;
const GAME_TICKER_MS = 100;
let peopleAccessingTheWebsite = 0;
let players = {};
let playerChannels = {};
let shipX = Math.floor((Math.random() * 1370 + 30) * 1000) / 1000;
let shipY = SHIP_PLATFORM;
let avatarColors = ["green", "cyan", "yellow"];
let avatarTypes = ["A", "B", "C"];
let gameOn = false;
let alivePlayers = 0;
let totalPlayers = 0;
let gameRoom;
let deadPlayerCh;
let gameTickerOn = false;
let bulletTimer = 0;
let shipBody;
let world;
let shipVelocityTimer = 0;
let killerBulletId = "";
let copyOfShipBody = {
position: "",
velocity: "",
};
view raw server.js hosted with ❀ by GitHub

Which libraries did we require and why?

  • The Express NPM library lets our server listen and respond to requests from clients.
  • The Ably NPM library allows the server to use Ably's Realtime messaging architecture to communicate in realtime with all the players using the Pub/Sub messaging architecture, over WebSockets in this case.
  • The p2 NPM library allows us to compute physics for ship velocity and position

Next, we need to authenticate the server with Ably and also instantiate the Express server so it can start listening to various endpoints:

const realtime = Ably.Realtime({
key: ABLY_API_KEY,
echoMessages: false,
});
//create a uniqueId to assign to clients on auth
const uniqueId = function () {
return "id-" + totalPlayers + Math.random().toString(36).substr(2, 16);
};
app.use(express.static("js"));
app.get("/auth", (request, response) => {
const tokenParams = { clientId: uniqueId() };
realtime.auth.createTokenRequest(tokenParams, function (err, tokenRequest) {
if (err) {
response
.status(500)
.send("Error requesting token: " + JSON.stringify(err));
} else {
response.setHeader("Content-Type", "application/json");
response.send(JSON.stringify(tokenRequest));
}
});
});
app.get("/", (request, response) => {
response.header("Access-Control-Allow-Origin", "*");
response.header(
"Access-Control-Allow-Headers",
"Origin, X-Requested-With, Content-Type, Accept"
);
if (++peopleAccessingTheWebsite > MIN_PLAYERS_TO_START_GAME) {
response.sendFile(__dirname + "/views/gameRoomFull.html");
} else {
response.sendFile(__dirname + "/views/intro.html");
}
});
app.get("/gameplay", (request, response) => {
response.sendFile(__dirname + "/views/index.html");
});
app.get("/winner", (request, response) => {
response.sendFile(__dirname + "/views/winner.html");
});
app.get("/gameover", (request, response) => {
response.sendFile(__dirname + "/views/gameover.html");
});
const listener = app.listen(process.env.PORT, () => {
console.log("Your app is listening on port " + listener.address().port);
});
view raw server.js hosted with ❀ by GitHub

As you can see, we've used Ably's Realtime library, passed an API Key to it, and set the echoMessages client option to false. This stops the server from being able to receive its own messages. You can explore the full list of Ably client options on the docs page. Please note that the ABLY_API_KEY variable is coming from the secret .env file, so make sure to create a free account with Ably to get your own API key to use here.

In the auth endpoint, we've assigned the client a randomly created unique ID and sent back an Ably signed token in the response. Any client(player) can then use that token to authenticate with Ably.

As a side note, Ably offers two auth strategies: Basic and Token auth. In short, Basic auth requires using the API key directly, whereas Token auth requires using a token provided by an auth server (like we implemented above).

The token expires after a certain period, and thus it needs to be updated at a regular interval. The token auth strategy offers the highest level of security, whereas the basic auth strategy exposes the API Key directly in the client-side code, making it prone to compromise. This is why we recomment token auth for any production level app.

In our code above, we also keep a track of the number of players trying to access the game using the peopleAccessingTheWebsite variable. Anyone who goes over the limit gets shown a separate page instead of adding them to the game. Ideally, we'd implement game rooms where multiple games could be played simultaneously, but that's something for the future commits to the project.

Other than handling client requests and sending different HTML pages in the responses, the server also needs to handle the game state and listen to user input and update all the context accordingly. Once the connection with Ably is established, we'll attach to the channels and subscribe to some events:

realtime.connection.once("connected", () => {
gameRoom = realtime.channels.get("game-room");
deadPlayerCh = realtime.channels.get("dead-player");
gameRoom.presence.subscribe("enter", (player) => {});
gameRoom.presence.subscribe("leave", (player) => {});
deadPlayerCh.subscribe("dead-notif", (msg) => {});
});
view raw server.js hosted with ❀ by GitHub

If you remember from the last chapter, we have two main channels in our game, the gameRoom channel for updates related to the game context and players entering/leaving, and the deadPlayerCh channel for updates related to any player's death.

On the gameRoom channel, we'll listen to the enter and leave events as these will be triggered when any client joins or leaves the game via a feature called presence. We'll learn more about this when we look at the client-side code.

Let's flesh each of these functions out next to understand what's happening:

  • gameRoom.presence.subscribe("enter", (msg) => {});
gameRoom.presence.subscribe("enter", (player) => {
let newPlayerId;
let newPlayerData;
alivePlayers++;
totalPlayers++;
if (totalPlayers === 1) {
gameTickerOn = true;
startGameDataTicker();
}
newPlayerId = player.clientId;
playerChannels[newPlayerId] = realtime.channels.get(
"clientChannel-" + player.clientId
);
newPlayerObject = {
id: newPlayerId,
x: Math.floor((Math.random() * 1370 + 30) * 1000) / 1000,
y: 20,
invaderAvatarType: avatarTypes[randomAvatarSelector()],
invaderAvatarColor: avatarColors[randomAvatarSelector()],
score: 0,
nickname: player.data,
isAlive: true,
};
players[newPlayerId] = newPlayerObject;
if (totalPlayers === MIN_PLAYERS_TO_START_GAME) {
startShipAndBullets();
}
subscribeToPlayerInput(playerChannels[newPlayerId], newPlayerId);
});
view raw server.js hosted with ❀ by GitHub

Let's figure out what's happening in the above method. When a new player joins, we update the alivePlayers and totalPlayers variables. If it's the first person to join, we start the game ticker, which publishes an update on the gameRoom channel every 100ms (we'll add this game tick implementation later).

Subsequently, we create a unique channel for each client using their clientId, so they can publish their button click inputs.

Next, we create an object for this new player, with all requisite attributes:

  • ID
  • x and y positions
  • avatar type and colour
  • score
  • nickname
  • a flag to see if the player is alive or not

We then add this object to the global associative array called players with a key that's same as the clientId of this player.

We also need to check if the max number of players has filled. If yes, we call a method to start the ship and the bullet and move the players downwards. We'll implement these methods later.

Finally, we call a method to subscribe to the unique channel we just created for this player. This allows the server to listen to key presses from the client and update the game state accordingly.

  • gameRoom.presence.subscribe("leave", (msg) => {});
gameRoom.presence.subscribe("leave", (player) => {
let leavingPlayer = player.clientId;
alivePlayers--;
totalPlayers--;
delete players[leavingPlayer];
if (totalPlayers <= 0) {
resetServerState();
}
});
view raw server.js hosted with ❀ by GitHub

Before we get into the explanation, a quick thing to note is that the leave event is invoked when a player gets disconnected from the internet or closes the game window. If that happens, we update the alivePlayers and totalPlayers variables and then delete that player's entry from the global associative array players. If it's the last player that has left, we call a method to reset the server context allowing a new round of the game to be played.

  • deadPlayerCh.subscribe("dead-notif", (msg) => {});
deadPlayerCh.subscribe("dead-notif", (msg) => {
players[msg.data.deadPlayerId].isAlive = false;
killerBulletId = msg.data.killerBulletId;
alivePlayers--;
if (alivePlayers == 0) {
setTimeout(() => {
finishGame("");
}, 1000);
}
});
view raw server.js hosted with ❀ by GitHub

In the client-side code, the event dead-notif would be published on this channel when a bullet hits a player's avatar, declaring the player dead.

When the server receives this event, we set the player's isAlive to false. We won't delete the player's entry from the players global associative array because even though they're dead, this player is still part of the gameand we'll need their info for the leaderboard at the end of the game.

The server needs to share this information with all the players in the next game tick, so we save the ID of the bullet that killed this player. In the client-side code this information is relevant to be able to destroy the killer bullet and the avatar of the player that was killed.

Those are pretty much the subscriptions we have inside the realtime.connection.once("connected", () => {}); callback. Let's next declare all the other functions we need in server.js to get a nice overview. We'll define each of these and understand their part in the game.

function startGameDataTicker() {}
function subscribeToPlayerInput(channelInstance, playerId) {}
function startDownwardMovement(playerId) {}
function finishGame(playerId) {}
function resetServerState() {}
function startShipAndBullets() {}
function startMovingPhysicsWorld() {}
function calcRandomVelocity() {}
function randomAvatarSelector() {}
view raw server.js hosted with ❀ by GitHub

Let's define these one by one.

  • startGameDataTicker():
function startGameDataTicker() {
let tickInterval = setInterval(() => {
if (!gameTickerOn) {
clearInterval(tickInterval);
} else {
bulletOrBlank = "";
bulletTimer += GAME_TICKER_MS;
if (bulletTimer >= GAME_TICKER_MS * 5) {
bulletTimer = 0;
bulletOrBlank = {
y: SHIP_PLATFORM,
id:
"bulletId-" + Math.floor((Math.random() * 2000 + 50) * 1000) / 1000,
};
}
if (shipBody) {
copyOfShipBody = shipBody;
}
gameRoom.publish("game-state", {
players: players,
playerCount: totalPlayers,
shipBody: copyOfShipBody.position,
bulletOrBlank: bulletOrBlank,
gameOn: gameOn,
killerBullet: killerBulletId,
});
}
}, GAME_TICKER_MS);
}
view raw server.js hosted with ❀ by GitHub

This is the most critical method in the whole game as it is responsible to publish updates at a preset frequency (in this case 100ms set by GAME_TICKER_MS). All the clients will then use these updates to update their respective game state as per these updates.

In every tick, we publish, among other things, the latest info from the players associative array that holds all the players' info and the ship's position and velocity as per the physics world (which we'll implement shortly).

  • subscribeToPlayerInput():
function subscribeToPlayerInput(channelInstance, playerId) {
channelInstance.subscribe("pos", (msg) => {
if (msg.data.keyPressed == "left") {
if (players[playerId].x - 20 < 20) {
players[playerId].x = 20;
} else {
players[playerId].x -= 20;
}
} else if (msg.data.keyPressed == "right") {
if (players[playerId].x + 20 > 1380) {
players[playerId].x = 1380;
} else {
players[playerId].x += 20;
}
}
});
}
view raw server.js hosted with ❀ by GitHub

Using this method we subscribe to the pos event on the particular client's unique channel. Note that this method is called for every client with their unique channel name). When the callback is invoked, we check to see if it was a left or right arrow click from the client, and change their avatar's position info accordingly. We also add a check to make sure they are not going out of bounds of the canvas.

  • startDownwardMovement()

This will be called when the game starts, i.e. when all the expected number of players have joined

function startDownwardMovement(playerId) {
let interval = setInterval(() => {
if (players[playerId] && players[playerId].isAlive) {
players[playerId].y += PLAYER_VERTICAL_INCREMENT;
players[playerId].score += PLAYER_SCORE_INCREMENT;
if (players[playerId].y > SHIP_PLATFORM) {
finishGame(playerId);
clearInterval(interval);
}
} else {
clearInterval(interval);
}
}, PLAYER_VERTICAL_MOVEMENT_UPDATE_INTERVAL);
}
view raw server.js hosted with ❀ by GitHub

As seen in the gameplay gif in the first article, all the players automatically move downward at a regular interval. The above function in the server does that update in the y position for each avatar. We loop through each player in the players array and update their avatar's y position if they are still alive. We also check each time whether they've reached the x-axis along which the ship is moving. If yes, it means they've won, so we'll call another function to finish the game for all players and show the leaderboard page.

Let's define that method next.

  • finishGame(playerId):
function finishGame(playerId) {
let firstRunnerUpName = "";
let secondRunnerUpName = "";
let winnerName = "Nobody";
let leftoverPlayers = new Array();
for (let item in players) {
leftoverPlayers.push({
nickname: players[item].nickname,
score: players[item].score,
});
}
leftoverPlayers.sort((a, b) => {
return b.score - a.score;
});
if (playerId == "") {
if (leftoverPlayers.length >= 3) {
firstRunnerUpName = leftoverPlayers[0].nickname;
secondRunnerUpName = leftoverPlayers[1].nickname;
} else if (leftoverPlayers == 2) {
firstRunnerUp = leftoverPlayers[0].nickname;
}
} else {
winnerName = players[playerId].nickname;
if (leftoverPlayers.length >= 3) {
firstRunnerUpName = leftoverPlayers[1].nickname;
secondRunnerUpName = leftoverPlayers[2].nickname;
} else if (leftoverPlayers.length == 2) {
firstRunnerUpName = leftoverPlayers[1].nickname;
}
}
gameRoom.publish("game-over", {
winner: winnerName,
firstRunnerUp: firstRunnerUpName,
secondRunnerUp: secondRunnerUpName,
totalPlayers: totalPlayers,
});
resetServerState();
}
view raw server.js hosted with ❀ by GitHub

The above method will be called either when a player has won the game or when all the players in the game have died.

We basically put all the leftover players in a new array with their score and nickname, sort them in descending order by score and declare a winner, runner up and second runner up (if the game has three players or more). We then publish this info on the gameRoom channel so all the clients can switch to the leaderboard screen and display this info.

At the end, we call the resetServerState() method which would reset all the counters on the server making it ready to host a new round.

  • resetServerState():
function resetServerState() {
peopleAccessingTheWebsite = 0;
gameOn = false;
gameTickerOn = false;
totalPlayers = 0;
alivePlayers = 0;
for (let item in playerChannels) {
playerChannels[item].unsubscribe();
}
}
view raw server.js hosted with ❀ by GitHub

We reset all the counters and flags to their initial state. We also detach from all the player channels since we no longer need them.

  • startShipAndBullets():
function startShipAndBullets() {
gameOn = true;
world = new p2.World({
gravity: [0, -9.82],
});
shipBody = new p2.Body({
position: [shipX, shipY],
velocity: [calcRandomVelocity(), 0],
});
world.addBody(shipBody);
startMovingPhysicsWorld();
for (let playerId in players) {
startDownwardMovement(playerId);
}
}
view raw server.js hosted with ❀ by GitHub

This method is called when the required number of players have joined the game, meaning we are ready to start the game.

We start by setting the gameOn flag to true. As mentioned before, we'll use the p2 Physics engine on the server-side to manage the movement of the ship. p2 needs a World instance to be created. We can set the frequency at which this world moves forward, moving its constituent objects along with it at that speed.

We then create a new Body instance for the ship, assign it the inital x/y positions and horizontal/vertical velocities. We add this ship body to the previously created world and call a method to start moving this world. This is when we'd like to start moving the players downwards, so we call that method here.

  • startMovingPhysicsWorld():
function startMovingPhysicsWorld() {
let p2WorldInterval = setInterval(function () {
if (!gameOn) {
clearInterval(p2WorldInterval);
} else {
// updates velocity every 5 seconds
if (++shipVelocityTimer >= 80) {
shipVelocityTimer = 0;
shipBody.velocity[0] = calcRandomVelocity();
}
world.step(P2_WORLD_TIME_STEP);
if (shipBody.position[0] > 1400 && shipBody.velocity[0] > 0) {
shipBody.position[0] = 0;
} else if (shipBody.position[0] < 0 && shipBody.velocity[0] < 0) {
shipBody.position[0] = 1400;
}
}
}, 1000 * P2_WORLD_TIME_STEP);
}
view raw server.js hosted with ❀ by GitHub

We start an interval and move the world with the speed of our choice. We basically update the shipBody variable's x/y positions and velocity according to what it is in the physics world at that time. Think of it as the engine moving the ship body with a certain speed towards the right. So if you'd like to know where the ship will be after, say, 2 seconds, the p2 world will tell you exactly that. We can use this info to update the variables that are sent as part of the next game tick update.

  • calcRandomVelocity():
function calcRandomVelocity() {
let randomShipXVelocity = Math.floor(Math.random() * 200) + 20;
randomShipXVelocity *= Math.floor(Math.random() * 2) == 1 ? 1 : -1;
return randomShipXVelocity;
}
view raw server.js hosted with ❀ by GitHub
  • randomAvatarSelector():
function randomAvatarSelector() {
return Math.floor(Math.random() * 3);
}
view raw server.js hosted with ❀ by GitHub

The calcRandomVelocity() calculates a random velocity which could be either negative (left) or positive (right). The randomAvatarSelector() simply returns a random number between 1 and 3, so each player can get assigned a random avatar type and colour out of the three we have available.


That's it on the server side. In the next chapter, we'll get back to the script.js file and finish up the game logic.

All articles in this series:

A separate release relevant to this tutorial is available on GitHub if you'd like to check it out.

You can also follow the Github project for latest developments on this project.


As usual, if you have any questions, please feel free to reach out to me on Twitter @Srushtika. My DMs are open :)

Sentry blog image

How I fixed 20 seconds of lag for every user in just 20 minutes.

Our AI agent was running 10-20 seconds slower than it should, impacting both our own developers and our early adopters. See how I used Sentry Profiling to fix it in record time.

Read more

Top comments (5)

Collapse
 
lakaschus profile image
lakaschus β€’

I'm a little confused about where to save the API key. I guess I shouldn't place it directly in the code, because then it would be exposed. So you mention a secret .env file. Do I just have to paste the key in the process.env file? How does the code read the API key from this file?
Thank you for this tutorial!

Collapse
 
srushtika profile image
Srushtika Neelakantam β€’

Hey - yes. So the front end client doesn't use a key directly, it uses a token (which is why you see an authUrl). As for the backend server, it uses the API directly via the env variables. Your .env file at the root of the project would look as follows:

ABLY_API_KEY=yourapikeyvalue
PORT=5000
Enter fullscreen mode Exit fullscreen mode

This can be accessed from the code as follows:

const envConfig = require("dotenv").config();
const ABLY_API_KEY = process.env.ABLY_API_KEY;
Enter fullscreen mode Exit fullscreen mode
Collapse
 
alaskathunderfx profile image
alaskaThunderfx β€’

Thank you! I wasn't sure about this as well, and this also answered my question!

Collapse
 
angel17 profile image
Angel Superstore β€’

Your post is so helpful. Thanks for sharing all those information. masonry contractors san antonio tx

Collapse
 
vmx profile image
Mohsen Tabatabaie β€’

i cant find where should i put my api key ?

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs