[Translation] Creating a multiplayer web game in the genre .io

[Translation] Creating a multiplayer web game in the genre .io


image

Released in 2015, Agar.io became the progenitor of a new genre .io games , the popularity of which has greatly increased since then. I experienced an increase in the popularity of .io games: over the past three years I created and sold two games of this genre. .

In case you have never heard of such games before: these are free multiplayer web games that are easy to participate in (no account is required). Usually they push together a number of opposing players in the same arena. Other famous games of the .io genre: Slither.io and Diep.io .

In this post, we will figure out how to create a .io game from scratch . All you need is just a knowledge of Javascript: you need to understand things like the syntax ES6 , the keywords this and Promises . Even if you know Javascript is not perfect, you can still figure out most of the post.

.io game example


We will refer to the example of the .io game for help in learning. Try playing it!


The game is quite simple: you control the ship in the arena, where there are other players. Your ship automatically fires shells and you try to hit other players while avoiding their shells.

1. Overview/Project Structure


I recommend download the source code of the example game, so you can follow me.

The example uses the following:

  • Express is the most popular Node.js web framework that manages the game’s web server.
  • socket.io - a websocket library for exchanging data between the browser and the server.
  • Webpack is a module manager. You can read about here about why using a Webpack.

Here is the structure of the project directory:

 public/
  assets/
  ...
 src/
  client/
  css/
  ...
  html/
  index.html
  index.js
  ...
  server/
  server.js
  ...
  shared/
  constants.js 

public/


Everything in the public/ folder will be statically transmitted by the server. The public/assets/ contains the images used by our project.

src/


All source code is in the src/ folder. The names of client/ and server/ speak for themselves, and shared/ contains a file of constants imported by both the client and the server.

2. Build/project options


As mentioned above, to build the project we use the module manager Webpack . Let's take a look at our Webpack configuration:

webpack.common.js:

  const path = require ('path');
 const MiniCssExtractPlugin = require ('mini-css-extract-plugin');

 module.exports = {
  entry: {
  game: './src/client/index.js',
  },
  output: {
  filename: '[name]. [contenthash] .js',
  path: path.resolve (__ dirname, 'dist'),
  },
  module: {
  rules: [
  {
  test:/\.js$/,
  exclude:/node_modules/,
  use: {
  loader: "babel-loader",
  options: {
  presets: ['@ babel/preset-env'],
  },
  },
  },
  {
  test:/\.css$/,
  use: [
  {
  loader: MiniCssExtractPlugin.loader,
  },
  'css-loader',
  ],
  },
  ],
  },
  plugins: [
  new MiniCssExtractPlugin ({
  filename: '[name]. [contenthash] .css',
  })
  new HtmlWebpackPlugin ({
  filename: 'index.html',
  template: 'src/client/html/index.html',
  })
  ],
 };  

The most important here are the following lines:

  • src/client/index.js is the input point of the Javascript client (JS). Webpack will start from here and recursively look for other imported files.
  • The output JS of our Webpack assembly will be located in the dist/ directory. I will call this file our JS package .
  • We use Babel , and in particular @ babel/preset-env for transpiling our JS code for old browsers.
  • We use the plugin to extract all the CSS referenced by the JS files, and to combine them in one place. I will call it our CSS package .

You may have noticed the strange file names of the '[name]. [Contenthash] .ext' files. They contain file name substitution Webpack: [name] will be replaced with the name of the input point (in our case, this is game ), and [contenthash] will be replaced with the hash of the file contents. We do this to optimize the project for hashing - you can order browsers to endlessly cache our JS packages, because if a package changes, then its file name also changes ( contenthash changes). The finished result is the file name of the form game.dbeee76e91a97d0c7207.js .

The webpack.common.js file is the base configuration file that we import into the development and development project configurations. Here, for example, development configuration:

webpack.dev.js

  const merge = require ('webpack-merge');
 const common = require ('./webpack.common.js');

 module.exports = merge (common, {
  mode: 'development',
 });  

For efficiency, we use webpack.dev.js in the development process, and switch to webpack.prod.js to optimize the size of the packages when deploying to production.

Local Setup


I recommend installing the project on a local machine so that you can follow the steps listed in this post. The setup is simple: first, Node and NPM . Next you need to perform

  $ git clone https://github.com/vzhou842/example-.io-game.git
 $ cd example-.io-game
 $ npm install  

and you are ready to go! To start the development server, just run

  $ npm run develop  

and go to the localhost: 3000 in a web browser. The development server will automatically rebuild the JS and CSS packages as the code changes - just refresh the page to see all the changes!

3. Customer Entry Points


Let's get to the game code itself. To begin, we will need the index.html page, when visiting the site, the browser will load it first.Our page will be pretty simple:

index.html

 & lt;! DOCTYPE html & gt;
 & lt; html & gt;
 & lt; head & gt;
  & lt; title & gt; An example .io game & lt;/title & gt;
  & lt; link type = "text/css" rel = "stylesheet" href = "/game.bundle.css" & gt;
 & lt;/head & gt;
 & lt; body & gt;
  & lt; canvas id = "game-canvas" & gt; & lt;/canvas & gt;
  & lt; script async src = "/game.bundle.js" & gt; & lt;/script & gt;
  & lt; div id = "play-menu" class = "hidden" & gt;
  & lt; input type = "text" id = "username-input" placeholder = "Username"/& gt;
  & lt; button id = "play-button" & gt; PLAY & lt;/button & gt;
  & lt;/div & gt;
 & lt;/body & gt;
 & lt;/html & gt; 

This sample code is slightly simplified for clarity, I will do the same with many other post examples. You can always see the full code at Github .

We have:

  • HTML5 Canvas element ( & lt; canvas & gt; ) that we will use for rendering the game.
  • & lt; link & gt; to add our CSS package.
  • & lt; script & gt; to add our Javascript package.
  • A main menu with the username & lt; input & gt; and the PLAY button ( & lt; button & gt; ).

After loading the home page in the browser, the Javascript code will start running from the input point JS file: src/client/index.js .

index.js

  import {connect, play} from './networking';
 import {startRendering, stopRendering} from './render';
 import {startCapturingInput, stopCapturingInput} from './input';
 import {downloadAssets} from './assets';
 import {initState} from './state';
 import {setLeaderboardHidden} from './leaderboard';

 import './css/main.css';

 const playMenu = document.getElementById ('play-menu');
 const playButton = document.getElementById ('play-button');
 const usernameInput = document.getElementById ('username-input');

 Promise.all ([
  connect ()
  downloadAssets (),
 ]). then (() = & gt; {
  playMenu.classList.remove ('hidden');
  usernameInput.focus ();
  playButton.onclick = () = & gt;  {
//Play!
  play (usernameInput.value);
  playMenu.classList.add ('hidden');
  initState ();
  startCapturingInput ();
  startRendering ();
  setLeaderboardHidden (false);
  };
 });  

This may seem complicated, but in reality there are not so many actions happening here:

  1. Import several other JS files.
  2. Import CSS (for Webpack to know that you need to include it in our CSS package).
  3. Run connect () to establish a connection with the server and launch downloadAssets () to download the images necessary to render the game.
  4. When stage 3 is completed , the main menu ( playMenu ) is displayed.
  5. Set up a handler for pressing the “PLAY” button. When the button is pressed, the code initializes the game and informs the server that we are ready to play.

The main "meat" of our client-server logic is in those files that were imported by the file index.js . Now we look at them all in order.

4. Customer Data Exchange


In this game, we use the well-known socket.io library to communicate with the server. Socket.io has built-in support for WebSockets that are well suited for two-way communication: we can send messages to the server and the server can send messages to us via the same connection.

We will have one src/client/networking.js file that will all communicate with the server:

networking.js

  import io from 'socket.io-client';
 import {processGameUpdate} from './state';

 const Constants = require ('../shared/constants');

 const socket = io (`ws://$ {window.location.host} `);
 const connectedPromise = new Promise (resolve = & gt; {
  socket.on ('connect', () = & gt; {
  console.log ('Connected to server!');
  resolve ();
  });
 });

 export const connect = onGameOver = & gt;  (
  connectedPromise.then (() = & gt; {
//Register callbacks
  socket.on (Constants.MSG_TYPES.GAME_UPDATE, processGameUpdate);
  socket.on (Constants.MSG_TYPES.GAME_OVER, onGameOver);
  })
 );

 export const play = username = & gt;  {
  socket.emit (Constants.MSG_TYPES.JOIN_GAME, username);
 };

 export const updateDirection = dir = & gt;  {
  socket.emit (Constants.MSG_TYPES.INPUT, dir);
 };  

This code is also slightly shortened for clarity.

There are three main actions in this file:

  • We are trying to connect to the server. connectedPromise is only allowed when we have established a connection.
  • If the connection is successfully established, we register callback functions ( processGameUpdate () and onGameOver () ) for messages that we can receive from the server.
  • Export play () and updateDirection () so that other files can use them.

5. Client rendering


It is time to display a picture on the screen!

... but before we can do this, you need to download all the images (resources) that are needed for this. Let's write a resource manager:

assets.js

  const ASSET_NAMES = ['ship.svg', 'bullet.svg'];

 const assets = {};
 const downloadPromise = Promise.all (ASSET_NAMES.map (downloadAsset));

 function downloadAsset (assetName) {
  return new Promise (resolve = & gt; {
  const asset = new Image ();
  asset.onload = () = & gt;  {
  console.log (`Downloaded $ {assetName}`);
  assets [assetName] = asset;
  resolve ();
  };
  asset.src = `/assets/$ {assetName}`;
  });
 }

 export const downloadAssets = () = & gt;  downloadPromise;
 export const getAsset = assetName = & gt;  assets [assetName];  

Resource management is not so difficult to implement! The main point is to store the assets object, which will bind the key of the file name to the value of the Image object. When a resource loads, we save it to a assets object for quick retrieval in the future. When each individual resource is allowed to download (i.e., all resources are downloaded), we allow downloadPromise .

After downloading the resources, you can start rendering. As mentioned earlier, we use HTML5 Canvas ( & lt; canvas & gt; ). Our game is pretty simple, so we only need to draw the following:

  1. Background
  2. Player Ship
  3. Other players in the game
  4. Shells

Here are the important pieces of src/client/render.js that draw exactly the four points listed above:

render.js

  import {getAsset} from './assets';
 import {getCurrentState} from './state';

 const Constants = require ('../shared/constants');
 const {PLAYER_RADIUS, PLAYER_MAX_HP, BULLET_RADIUS, MAP_SIZE} = Constants;
//Get the canvas graphics context
 const canvas = document.getElementById ('game-canvas');
 const context = canvas.getContext ('2d');
//Make the canvas fullscreen
 canvas.width = window.innerWidth;
 canvas.height = window.innerHeight;

 function render () {
  const {me, others, bullets} = getCurrentState ();
  if (! me) {
  return;
  }

//Draw background
  renderBackground (me.x, me.y);

//Draw all bullets
  bullets.forEach (renderBullet.bind (null, me));

//Draw all players
  renderPlayer (me, me);
  others.forEach (renderPlayer.bind (null, me));
 }
//...Helper functions here excluded

 let renderInterval = null;
 export function startRendering () {
  renderInterval = setInterval (render, 1000/60);
 }
 export function stopRendering () {
  clearInterval (renderInterval);
 }  

This code is also abbreviated for clarity.

render () is the main function of this file. startRendering () and stopRendering () control the activation of the render cycle at 60 FPS.

Specific implementations of individual rendering rendering functions (for example renderBullet () ) are not so important, but here’s one simple example:

render.js

  function renderBullet (me, bullet) {
  const {x, y} = bullet;
  context.drawImage (
  getAsset ('bullet.svg'),
  canvas.width/2 + x - me.x - BULLET_RADIUS,
  canvas.height/2 + y - me.y - BULLET_RADIUS,
  BULLET_RADIUS * 2,
  BULLET_RADIUS * 2,
  );
 }  

Notice that we use the getAsset () method that was previously seen in asset.js !

If you are interested in exploring other rendering functions, read the rest of src/client/render.js .

6. Client input


It's time to make the game playable ! The control scheme will be very simple: you can use the mouse (on a computer) or touch the screen (on a mobile device) to change the direction of movement. To do this, we will register Event Listeners for Mouse and Touch events.
All of this will take src/client/input.js :

input.js

  import {updateDirection} from './networking';

 function onMouseInput (e) {
  handleInput (e.clientX, e.clientY);
 }

 function onTouchInput (e) {
  const touch = e.touches [0];
  handleInput (touch.clientX, touch.clientY);
 }

 function handleInput (x, y) {
  const dir = Math.atan2 (x - window.innerWidth/2, window.innerHeight/2 - y);
  updateDirection (dir);
 }

 export function startCapturingInput () {
  window.addEventListener ('mousemove', onMouseInput);
  window.addEventListener ('touchmove', onTouchInput);
 }

 export function stopCapturingInput () {
  window.removeEventListener ('mousemove', onMouseInput);
  window.removeEventListener ('touchmove', onTouchInput);
 }  

onMouseInput () and onTouchInput () are Event Listeners calling updateDirection () (from networking.js ) when making an input event (for example, when moving the mouse). updateDirection () communicates with the server that processes the input event and updates the game state accordingly.

7. Customer Status


This section is the most difficult in the first part of the post. Do not be discouraged if you do not understand it from the first reading! You can even skip it and come back to it later.

The last piece of the puzzle that is needed to complete the client-server code is state . Remember the snippet from the “Client Rendering” section?

render.js

  import {getCurrentState} from './state';

 function render () {
  const {me, others, bullets} = getCurrentState ();

//Do the rendering
//...
 }  

getCurrentState () should be able to provide us with the current game status in the client at any time based on updates received from the server. Here is an example of a game update that a server can send:

  {
  "t": 1555960373725,
  "me": {
  "x": 2213.8050880413657,
  "y": 1469.370893425012,
  "direction": 1.3082443894581433,
  "id": "AhzgAtklgo2FJvwWAADO",
  "hp": 100
  },
  "others": [],
  "bullets": [
  {
  "id": "RUJfJ8Y18n",
  "x": 2354.029197099604,
  "y": 1431.6848318262666
  },
  {
  "id": "ctg5rht5s",
  "x": 2260.546457727445,
  "y": 1456.8088728920968
  }
  ],
  "leaderboard": [
  {
  "username": "Player",
  "score": 3
  }
  ]
 }  

Each game update contains five identical fields:

  • t : the server timestamp for the time when this update was created.
  • me : information about the player receiving this update.
  • others : an array of information about other players participating in the same game.
  • bullets : an array of information about the shells in the game.
  • leaderboard : current leaderboard data. In this post we will not take them into account.

7.1 Naive client state


A naive implementation of getCurrentState () can only directly return the data of the latest game update received.

naive-state.js

  let lastGameUpdate = null;
//Handle a newly received game update.
 export function processGameUpdate (update) {
  lastGameUpdate = update;
 }

 export function getCurrentState () {
  return lastGameUpdate;
 }  

Beautiful and clear! But if everything was so simple. One of the reasons why this implementation is problematic is: it limits the rendering frame rate to the server clock frequency .

Frame Rate : the number of frames (i.e., calls render () ) per second, or FPS. In games, they usually strive to achieve at least 60 FPS.

Tick Rate : the frequency with which the server sends game updates to clients. Often it’s lower than the frame rate . In our game, the server runs at 30 clocks per second.

If we just render the latest update of the game, then the FPS will in fact never be able to exceed 30, because we never get more than 30 updates per second from the server . Even if we call render () 60 times per second, half of these calls will simply redraw the same thing, essentially without doing anything. Another problem with the naive implementation is that it is subject to delays . With ideal Internet speed, the client will receive a game update exactly every 33 ms (30 per second):


Unfortunately, nothing is perfect. A more realistic picture would be:

A naive implementation is practically the worst case when it comes to delays. If the game update is received with a delay of 50 ms, then the client is braked for an extra 50 ms, because it still renders the state of the game from the previous update. You can imagine how inconvenient this is for the player: because of arbitrary braking, the game will seem torn and unstable.

7.2 Improved customer status


We will make some improvements to the naive implementation. First, we use rendering delay for 100 ms. This means that the “current” client state will always lag behind the game state on the server by 100 ms. For example, if the server time is 150 , then the client will see the status of the server in 50 :


This gives us a 100 ms buffer that allows us to experience an unpredictable time for receiving game updates:


The price paid for this will be a constant input delay ( for 100 ms. This is a minor sacrifice for smooth gameplay - most players (especially casual ones) will not even notice this delay. People are much easier to adapt to a constant delay of 100 ms, than to play with an unpredictable delay.

We can use another technique called “client side prediction” , which copes well with the reduction of perceived delays, but in this post it will not be considered.

Another improvement we use is linear interpolation . Because of the delay in rendering, we usually overtake the current time in the client by at least one update. When getCurrentState () is called, we can perform linear interpolation between updates games immediately before and after the current time in the client:


This solves the problem with the frame rate: now we can render unique frames with any frequency we need!

7.3 Implementing an improved client state


An example implementation in src/client/state.js uses both rendering delay and linear interpolation, but this is not for long. Let's break the code into two parts. Here is the first:

state.js, part 1

  const RENDER_DELAY = 100;

 const gameUpdates = [];
 let gameStart = 0;
 let firstServerTimestamp = 0;

 export function initState () {
  gameStart = 0;
  firstServerTimestamp = 0;
 }

 export function processGameUpdate (update) {
  if (! firstServerTimestamp) {
  firstServerTimestamp = update.t;
  gameStart = Date.now ();
  }
  gameUpdates.push (update);

//Keep the one server update
  const base = getBaseUpdate ();
  if (base & gt; 0) {
  gameUpdates.splice (0, base);
  }
 }

 function currentServerTime () {
  Return firstServerTimestamp + (Date.now () - gameStart) - RENDER_DELAY;
 }
//Returns the index update//current server time, or -1 if N/A.
 function getBaseUpdate () {
  const serverTime = currentServerTime ();
  for (let i = gameUpdates.length - 1; i> = 0; i--) {
  if (gameUpdates [i] .t & lt; = serverTime) {
  return i;
  }
  }
  return -1;
 }  

The first step is to figure out what currentServerTime () does. As we saw earlier, the server timestamp is included in every game update. We want to use the rendering delay to render the picture lagging the server by 100 ms, but we will never know the current time on the server because we cannot know how long any of the updates reached us. The Internet is unpredictable and its speed can vary greatly!

To get around this problem, you can use a reasonable approximation: we pretend that the first update arrived instantly . If this were true, then we would know the server time at this particular moment! We save the server timestamp to firstServerTimestamp and save our local (client) timestamp at the same moment in gameStart .

Oh, wait a minute. Shouldn't there be server time = client time? Why do we distinguish between “server time stamp” and “client time stamp”? This is a great question! It turns out that this is not the same thing. Date.now () will return different time stamps in the client and server, and this depends on local factors for these machines. Never assume that the time stamps will be the same on all machines.

Now we understand what currentServerTime () does: it returns the time stamp of the current rendering time server . In other words, this is the current server time ( firstServerTimestamp & lt; + (Date.now () - gameStart) ) minus the rendering delay ( RENDER_DELAY ).

Now let's see how we handle game updates. When retrieving an update from the server, processGameUpdate () is called, and we save the new update to the gameUpdates array. Then, to check the memory usage, we delete all the old updates to the basic update , because we don’t need them anymore.

What is a "basic update"? This is the first update we find when moving back from the current server time . Remember this scheme?


Updating the game directly to the left of Client Render Time is a basic update.

What is the base update used for? Why can we drop updates to the base one? To understand this, let's finally consider the implementation of getCurrentState () :

state.js part 2

  export function getCurrentState () {
  if (! firstServerTimestamp) {
  return {};
  }

  const base = getBaseUpdate ();
  const serverTime = currentServerTime ();

//If it is the most recent update we have, use its state.
//Else, interpolate between the state and the state of (base + 1).
  if (base & lt; 0) {
  return gameUpdates [gameUpdates.length - 1];
  } else if (base === gameUpdates.length - 1) {
  return gameUpdates [base];
  } else {
  const baseUpdate = gameUpdates [base];
  const next = gameUpdates [base + 1];
  const r = (serverTime - baseUpdate.t)/(next.t - baseUpdate.t);
  return {
  me: interpolateObject (baseUpdate.me, next.me, r),
  others: interpolateObjectArray (baseUpdate.others, next.others, r),
  bullets: interpolateObjectArray (baseUpdate.bullets, next.bullets, r),
  };
  }
 }  

We handle three cases:

  1. base & lt; 0 means that there are no updates before the current rendering time (see the getBaseUpdate () implementation above). This can happen immediately at the beginning of the game due to the delay in rendering. In this case, we use the most recent update.
  2. base is the latest update we have. This may be due to network latency or poor Internet connection. In this case, we also use the latest update that we have.
  3. We have an update both before and after the current rendering time, so you can interpolate !

All that remains in state.js is the implementation of linear interpolation, which is simple (but boring) mathematics. If you want to explore it yourself, then open state.js at Github .

Part 2. Backend Server


In this installment, we will look at the Node.js backend, which manages our example of .io .

1. Server Entry Point


To manage a web server, we will use the popular Node.js web framework called Express . It will be configured by our src/server/server.js server input point file:

server.js part 1

  const express = require ('express');
 const webpack = require ('webpack');
 const webpackDevMiddleware = require ('webpack-dev-middleware');
 const webpackConfig = require ('../../webpack.dev.js');
//Setup an Express server
 const app = express ();
 app.use (express.static ('public'));

 if (process.env.NODE_ENV === 'development') {
//Setup Webpack for development
  const compiler = webpack (webpackConfig);
  app.use (webpackDevMiddleware (compiler));
 } else {
//Static serve the dist/folder in production
  app.use (express.static ('dist'));
 }
//Listen on port
 const port = process.env.PORT ||  3000;
 const server = app.listen (port);
 console.log (`Server listening on port $ {port}`);  

Remember that in the first part we discussed the Webpack? This is where we will use our Webpack configurations. We will apply them in two ways:

  • Use webpack-dev-middleware to automatically rebuild our development packages, or
  • Statically transfer the dist/ folder into which Webpack will write our files after the production build.

Another important task of server.js is to set up a socket.io server that simply connects to the Express server:

server.js part 2

  const socketio = require ('socket.io');
 const Constants = require ('../shared/constants');
//Setup Express//...
 const server = app.listen (port);
 console.log (`Server listening on port $ {port}`);
//Setup socket.io
 const io = socketio (server);
//Listen for socket.io connections
 io.on ('connection', socket = & gt; {
  console.log ('Player connected!', socket.id);

  socket.on (Constants.MSG_TYPES.JOIN_GAME, joinGame);
  socket.on (Constants.MSG_TYPES.INPUT, handleInput);
  socket.on ('disconnect', onDisconnect);
 });  

After successfully connecting the socket.io to the server, we set up event handlers for the new socket. Event handlers process messages received from clients by delegating to the singleton object game :

server.js part 3

  const Game = require ('./game');
//...
//Setup the Game
 const game = new Game ();

 function joinGame (username) {
  game.addPlayer (this, username);
 }

 function handleInput (dir) {
  game.handleInput (this, dir);
 }

 function onDisconnect () {
  game.removePlayer (this);
 }  

We create a game of the .io genre, so we only need one instance of Game (“Game”) - all players play in the same arena! In the next section, we will look at how this Game class works.

2. Game Server


The Game class contains the most important server-side logic. It has two main tasks: player management and game simulation .

Let's start with the first task - managing the players.

game.js, part 1

  const Constants = require ('../shared/constants');
 const Player = require ('./player');

 class Game {
  constructor () {
  this.sockets = {};
  this.players = {};
  this.bullets = [];
  this.lastUpdateTime = Date.now ();
  this.shouldSendUpdate = false;
  setInterval (this.update.bind (this), 1000/60);
  }

  addPlayer (socket, username) {
  this.sockets [socket.id] = socket;

//Generate a position to start this player at.
  const x = Constants.MAP_SIZE * (0.25 + Math.random () * 0.5);
  const y = Constants.MAP_SIZE * (0.25 + Math.random () * 0.5);
  this.players [socket.id] = new Player (socket.id, username, x, y);
  }

  removePlayer (socket) {
  delete this.sockets [socket.id];
  delete this.players [socket.id];
  }

  handleInput (socket, dir) {
  if (this.players [socket.id]) {
  this.players [socket.id] .setDirection (dir);
  }
  }

//...}  

In this game, we will identify players by the id field of their socket.io socket (if you mess up, then go back to server.js ). Socket.io itself assigns a unique id to each socket, so we don’t need to worry about it. I will call him player ID .

With this in mind, let's examine the instance variables in the Game class:

  • sockets is the object that binds the player ID to the socket that is associated with the player. It allows us to gain access to sockets by their player IDs for a constant time.
  • players is the object that binds the player ID to the object code & gt; Player

bullets is an array of Bullet objects that does not have a specific order.
lastUpdateTime is the timestamp since the last update of the game. Soon we will see how it is used.
shouldSendUpdate is an auxiliary variable. We will see it soon too.
The addPlayer () , removePlayer () and handleInput () methods do not need to be explained, they are used in server.js . If you need to refresh your memory, go back a little higher.

The last line of the constructor () starts the update cycle of the game (60 updates per second):

game.js part 2

  const Constants = require ('../shared/constants');
 const applyCollisions = require ('./collisions');

 class Game {
//...

  update () {
//Calculate time elapsed
  const now = Date.now ();
  const dt = (now - this.lastUpdateTime)/1000;
  this.lastUpdateTime = now;

//Update each bullet
  const bulletsToRemove = [];
  this.bullets.forEach (bullet = & gt; {
  if (bullet.update (dt)) {
//Destroy this bullet
  bulletsToRemove.push (bullet);
  }
  });
  this.bullets = this.bullets.filter (
  bullet = & gt;  ! bulletsToRemove.includes (bullet),
  );

//Update each player
  Object.keys (this.sockets) .forEach (playerID = & gt; {
  const player = this.players [playerID];
  const newBullet = player.update (dt);
  if (newBullet) {
  this.bullets.push (newBullet);
  }
  });

//Apply collisions, give players score for hitting bullets
  const destroyedBullets = applyCollisions (
  Object.values ​​(this.players),
  this.bullets,
  );
  destroyedBullets.forEach (b = & gt; {
  if (this.players [b.parentID]) {
  this.players [b.parentID] .onDealtDamage ();
  }
  });
  this.bullets = this.bullets.filter (
  bullet = & gt;  ! destroyedBullets.includes (bullet),
  );

//Check if any players are dead
  Object.keys (this.sockets) .forEach (playerID = & gt; {
  const socket = this.sockets [playerID];
  const player = this.players [playerID];
  if (player.hp & lt; = 0) {
  socket.emit (Constants.MSG_TYPES.GAME_OVER);
  this.removePlayer (socket);
  }
  });

//Send a game
  if (this.shouldSendUpdate) {
  const leaderboard = this.getLeaderboard ();
  Object.keys (this.sockets) .forEach (playerID = & gt; {
  const socket = this.sockets [playerID];
  const player = this.players [playerID];
  socket.emit (
  Constants.MSG_TYPES.GAME_UPDATE,
  this.createUpdate (player, leaderboard),
  );
  });
  this.shouldSendUpdate = false;
  } else {
  this.shouldSendUpdate = true;
  }
  }

//...
 }  

The update () method probably contains the most important part of server-side logic. In order we list everything that he does:

  1. Calculates how much time dt has passed since the last update () .
  2. Updates each projectile and, if necessary, destroys them. We will see the implementation of this functionality later. For now, it’s enough for us to know that bullet.update () returns true if the projectile is to be destroyed (it has gone beyond the boundaries of the arena).
  3. Updates each player and, if necessary, creates a projectile. We will also see this implementation later - player.update () can return a Bullet object.
  4. Check for collisions between shells and players with applyCollisions () , which returns an array of shells that hit the players. For each returned projectile, we increase the points of the player who released it (using player.onDealtDamage () ), and then remove the projectile from the bullets array.
  5. Notifies and kills all killed players.
  6. Sends all players an update every second every time they call update () . This helps us keep track of the shouldSendUpdate helper variable mentioned above. Since update () is called 60 times/s, we send game updates 30 times/s. Thus, the clock frequency of the server is 30 clock cycles/s (we talked about the clock frequency in the first part).

Why send game updates only through ? To save the channel. 30 game updates per second is a lot!

Why then just not to call update () 30 times a second? To improve the simulation of the game. The more often update () is called, the more accurate the game simulation will be. But don't get too carried away with the number of update () calls, because this is a computationally expensive task - 60 per second is enough.

The rest of the Game class consists of helper methods used in update () :

game.js, part 3

  class Game {
//...

  getLeaderboard () {
  return Object.values ​​(this.players)
  .sort ((p1, p2) = & gt; p2.score - p1.score)
  .slice (0, 5)
  .map (p = & gt; ({username: p.username, score: Math.round (p.score)}));
  }

  createUpdate (player, leaderboard) {
  const nearbyPlayers = Object.values ​​(this.players) .filter (
  p = & gt;  p! == player & amp; & amp;  p.distanceTo (player) & lt; = Constants.MAP_SIZE/2,
  );
  const nearbyBullets = this.bullets.filter (
  b = & gt;  b.distanceTo (player) & lt; = Constants.MAP_SIZE/2,
  );

  return {
  t: Date.now (),
  me: player.serializeForUpdate (),
  others: nearbyPlayers.map (p = & gt; p.serializeForUpdate ()),
  bullets: nearbyBullets.map (b = & gt; b.serializeForUpdate ()),
  leaderboard,
  };
  }
 }  

getLeaderboard () is pretty simple - it sorts the players by the number of points, takes the top five and returns each user name and score.

createUpdate () is used in update () to create game updates that are passed to players. Its main task is to call the serializeForUpdate () methods implemented for the Player and Bullet classes. Notice that he gives each player data only about closest players and shells - there is no need to transfer information about game objects that are far from the player!

3. Game objects on the server


In our game, the shells and the players are actually very similar: they are abstract round moving game objects. To take advantage of this similarity between players and projectiles, let's start with the implementation of the Object base class:

object.js

  class Object {
  constructor (id, x, y, dir, speed) {
  this.id = id;
  this.x = x;
  this.y = y;
  this.direction = dir;
  this.speed = speed;
  }

  update (dt) {
  this.x + = dt * this.speed * Math.sin (this.direction);
  this.y - = dt * this.speed * Math.cos (this.direction);
  }

  distanceTo (object) {
  const dx = this.x - object.x;
  const dy = this.y - object.y;
  return Math.sqrt (dx * dx + dy * dy);
  }

  setDirection (dir) {
  this.direction = dir;
  }

  serializeForUpdate () {
  return {
  id: this.id,
  x: this.x,
  y: this.y,
  };
  }
 }  

There is nothing complicated going on here. This class will be a good reference point for expansion.Let's see how the Bullet class uses Object :

bullet.js

  const shortid = require ('shortid');
 const ObjectClass = require ('./object');
 const Constants = require ('../shared/constants');

 class Bullet extends ObjectClass {
  constructor (parentID, x, y, dir) {
  super (shortid (), x, y, dir, Constants.BULLET_SPEED);
  this.parentID = parentID;
  }

//Returns true if the bullet should be destroyed
  update (dt) {
  super.update (dt);
  return this.x & lt;  0 ||  this.x & gt;  Constants.MAP_SIZE ||  this.y & lt;  0 ||  this.y & gt;  Constants.MAP_SIZE;
  }
 }  

The implementation of Bullet is very short! We added only the following extensions to Object :

  • Using the shortid package to randomly generate id projectile.
  • Add a parentID field so that you can track the player who created this projectile.
  • Adding a return value to update () , which is true if the projectile is outside the arena (remember, did we talk about this in the last section?).

Moving to Player :

player.js

  const ObjectClass = require ('./object');
 const Bullet = require ('./bullet');
 const Constants = require ('../shared/constants');

 class Player extends ObjectClass {
  constructor (id, username, x, y) {
  super (id, x, y, Math.random () * 2 * Math.PI, Constants.PLAYER_SPEED);
  this.username = username;
  this.hp = Constants.PLAYER_MAX_HP;
  this.fireCooldown = 0;
  this.score = 0;
  }

//Returns a newly created bullet, or null.
  update (dt) {
  super.update (dt);

//Update score
  this.score + = dt * Constants.SCORE_PER_SECOND;

//Make sure the player stays in bounds
  this.x = Math.max (0, Math.min (Constants.MAP_SIZE, this.x));
  this.y = Math.max (0, Math.min (Constants.MAP_SIZE, this.y));

//Fire a bullet, if needed
  this.fireCooldown - = dt;
  if (this.fireCooldown & lt; = 0) {
  this.fireCooldown + = Constants.PLAYER_FIRE_COOLDOWN;
  return new Bullet (this.id, this.x, this.y, this.direction);
  }
  return null;
  }

  takeBulletDamage () {
  this.hp - = Constants.BULLET_DAMAGE;
  }

  onDealtDamage () {
  this.score + = Constants.SCORE_BULLET_HIT;
  }

  serializeForUpdate () {
  return {
  ... (super.serializeForUpdate ()),
  direction: this.direction,
  hp: this.hp,
  };
  }
 }  

Players are harder than shells, so several more fields should be stored in this class. His update () method does a great job, in particular, it returns the newly created projectile if fireCooldown is left (remember, we talked about this in the previous section?). It also extends the serializeForUpdate () method, because we need to include additional fields in the game update for the player.

Having a Object base class is an important step to avoid code repetition . For example, without the Object class, each game object must have the same distanceTo () implementation, and synchronizing the copy-paste of all these implementations in several files would be a nightmare. This becomes especially important for large projects when the number of expanding Object classes grows.

4. Collision Detection


The only thing left for us is to recognize when the shells hit the players! Recall this code snippet from the update () method in the Game class:

game.js

  const applyCollisions = require ('./collisions');

 class Game {
//...

  update () {
//...

//Apply collisions, give players score for hitting bullets
  const destroyedBullets = applyCollisions (
  Object.values ​​(this.players),
  this.bullets,
  );
  destroyedBullets.forEach (b = & gt; {
  if (this.players [b.parentID]) {
  this.players [b.parentID].onDealtDamage ();
  }
  });
  this.bullets = this.bullets.filter (
  bullet = & gt;  ! destroyedBullets.includes (bullet),
  );

//...
  }
 }  

We need to implement the applyCollisions () method, which returns all the shells that hit the players. Fortunately, it is not so difficult to do, because

  • All colliding objects are circles, and this is the simplest figure for the implementation of collision recognition.
  • We already have the distanceTo () method that we implemented in the Object class in the previous section.

This is how our implementation of collision detection looks like:

collisions.js

  const Constants = require ('../shared/constants');
//Returns an array of bullets to be destroyed.
 function applyCollisions (players, bullets) {
  const destroyedBullets = [];
  for (let i = 0; i & lt; bullets.length; i ++) {
//Look for a bullet for each player.
//double counting a bullet.
  for (let j = 0; j & lt; players.length; j ++) {
  const bullet = bullets [i];
  const player = players [j];
  if (
  bullet.parentID! == player.id & amp; & amp;
  player.distanceTo (bullet) & lt; = Constants.PLAYER_RADIUS + Constants.BULLET_RADIUS
  ) {
  destroyedBullets.push (bullet);
  player.takeBulletDamage ();
  break;
  }
  }
  }
  return destroyedBullets;
 }  

This simple collision detection is based on the fact that two circles collide if the distance between their centers is less than the sum of their radii . This is the case when the distance between the centers of two circles is exactly equal to the sum of their radii:

Here you need to carefully consider a couple of aspects:

  • The projectile should not fall into the player who created it. This can be achieved by comparing bullet.parentID with player.id .
  • The projectile should hit only once in the limiting case of simultaneous collision with several players. We will solve this problem with the help of the break operator: as soon as the player who encounters the projectile is found, we stop searching and proceed to the next projectile.

End


That's all! We looked at everything you need to know to create a .io web game. What's next? Build your own .io game!

All example code has open source and is available on Github .

Source text: [Translation] Creating a multiplayer web game in the genre .io