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:
- Part 1: Introduction to gaming concepts and the Phaser library
- Part 2: Evaluating networking protocols for realtime apps
- Part 3: Implementing the server-side code to keep all players in sync
- Part 4: Finishing up the client-side code to render the game
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:
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:
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:
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) => {});
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) => {});
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) => {});
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.
Let's define these one by one.
-
startGameDataTicker()
:
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()
:
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
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)
:
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()
:
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()
:
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()
:
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()
:
-
randomAvatarSelector()
:
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:
- Part 1: Introduction to gaming concepts and the Phaser library
- Part 2: Evaluating networking protocols for realtime apps
- Part 3: Implementing the server-side code to keep all players in sync
- Part 4: Finishing up the client-side code to render the game
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 :)
Top comments (5)
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!
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:
This can be accessed from the code as follows:
Thank you! I wasn't sure about this as well, and this also answered my question!
Your post is so helpful. Thanks for sharing all those information. masonry contractors san antonio tx
i cant find where should i put my api key ?