Creating a Multiplayer .io Web Game

Creating a Multiplayer .io Web Game
Released in 2015 Agar.io became the progenitor of a new genre games .iowhich has grown in popularity since then. I have personally experienced the rise in popularity of .io games: over the past three years, I have created and sold two games of this genre..

In case you've never heard of these games before, these are free multiplayer web games that are easy to play (no account required). They usually face many opposing players in the same arena. Other famous .io games: Slither.io ΠΈ Diep.io.

In this post, we will explore how create an .io game from scratch. For this, only knowledge of Javascript will be enough: you need to understand things like syntax ES6, keyword this ΠΈ Promises. Even if your knowledge of Javascript is not perfect, you can still understand most of the post.

.io game example

For learning assistance, we will refer to .io game example. Try to play it!

Creating a Multiplayer .io Web Game
The game is quite simple: you control a ship in an arena where there are other players. Your ship automatically fires projectiles and you try to hit other players while avoiding their projectiles.

1. Brief overview / structure of the project

Recommended download source code 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 a browser and a server.
  • Webpack - module manager. You can read about why to use Webpack. here.

Here is what the project directory structure looks like:

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

public/

Everything in a folder public/ will be statically submitted by the server. IN public/assets/ contains images used by our project.

src /

All source code is in the folder src/. Titles client/ ΠΈ server/ speak for themselves and shared/ contains a constants file that is imported by both the client and the server.

2. Assemblies/project settings

As mentioned above, we use the module manager to build the project. 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 lines here are:

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

You may have noticed strange package filenames '[name].[contenthash].ext'. They contain filename substitutions webpack: [name] will be replaced with the name of the input point (in our case, this game), and [contenthash] will be replaced with a hash of the file's contents. We do it to optimize the project for hashing - you can tell browsers to cache our JS packages indefinitely, because if a package changes, then its file name also changes (changes contenthash). The final result will be the name of the view file game.dbeee76e91a97d0c7207.js.

File webpack.common.js is the base configuration file that we import into the development and finished project configurations. Here is an 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 in the development process webpack.dev.js, and switches to webpack.prod.jsto optimize package sizes when deploying to production.

Local setting

I recommend installing the project on a local machine so you can follow the steps listed in this post. The setup is simple: first, the system must have installed Node ΠΈ NPM. Next you need to do

$ 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 web browser localhost: 3000. 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. Client Entry Points

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

index.html

An example .io game  PLAY

This code example has been simplified slightly for clarity, and I will do the same with many of the other post examples. The full code can always be viewed at Github.

We have:

  • HTML5 canvas element (<canvas>) which we will use to render the game.
  • <link> to add our CSS package.
  • <script> to add our Javascript package.
  • Main menu with username <input> and the PLAY button (<button>).

After loading the home page, the browser will start executing Javascript code, starting from the entry 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(() => {
  playMenu.classList.remove('hidden');
  usernameInput.focus();
  playButton.onclick = () => {
    // Play!
    play(usernameInput.value);
    playMenu.classList.add('hidden');
    initState();
    startCapturingInput();
    startRendering();
    setLeaderboardHidden(false);
  };
});

This may sound complicated, but there isn't much going on here:

  1. Importing several other JS files.
  2. CSS import (so Webpack knows to include them in our CSS package).
  3. Release connect() to establish a connection with the server and run downloadAssets() to download images needed to render the game.
  4. After completion of stage 3 the main menu is displayed (playMenu).
  5. Setting the handler for pressing the "PLAY" button. When the button is pressed, the code initializes the game and tells 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 will consider them all in order.

4. Exchange of customer data

In this game, we use a well-known library to communicate with the server socket.io. Socket.io has native support WebSockets, which are well suited for two-way communication: we can send messages to the server ΠΈ the server can send messages to us on the same connection.

We will have one file src/client/networking.jswho will take care of all communication 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 => {
  socket.on('connect', () => {
    console.log('Connected to server!');
    resolve();
  });
});

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

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

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

This code has also been shortened slightly for clarity.

There are three main actions in this file:

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

5. Client Rendering

It's time to display the picture on the screen!

…but before we can do that, we 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 => {
    const asset = new Image();
    asset.onload = () => {
      console.log(`Downloaded ${assetName}`);
      assets[assetName] = asset;
      resolve();
    };
    asset.src = `/assets/${assetName}`;
  });
}

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

Resource management is not that hard to implement! The main idea is to store an object assets, which will bind the key of the filename to the value of the object Image. When the resource is loaded, we store it in an object assets for quick access in the future. When will each individual resource be allowed to download (that is, all resources), we allow downloadPromise.

After downloading the resources, you can start rendering. As said earlier, to draw on a web page, we use HTML5 Canvas (<canvas>). 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 snippets src/client/render.js, which render exactly the four items 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 shortened for clarity.

render() is the main function of this file. startRendering() ΠΈ stopRendering() control the activation of the render loop at 60 FPS.

Concrete implementations of individual rendering helper functions (e.g. renderBullet()) are not that important, but here is 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,
  );
}

Note that we are using the method getAsset(), which was previously seen in asset.js!

If you're interested in exploring other rendering helpers, then read the rest of src/client/render.js.

6. Client input

It's time to make a game playable! The control scheme will be very simple: to change the direction of movement, you can use the mouse (on a computer) or touch the screen (on a mobile device). To implement this, we will register Event Listeners for Mouse and Touch events.
Will take care of all this 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() ΠΈ onTouchInput() are Event Listeners that call updateDirection() (of networking.js) when an input event occurs (for example, when the mouse is moved). updateDirection() handles messaging with the server, which handles the input event and updates the game state accordingly.

7. Client Status

This section is the most difficult in the first part of the post. Don't be discouraged if you don't understand it the first time you read it! You can even skip it and come back to it later.

The last piece of the puzzle needed to complete the client/server code is state. Remember the code 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 give us the current state of the game in the client at any point in time based on updates received from the server. Here is an example of a game update that the 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: Server timestamp indicating 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 projectiles in the game.
  • leaderboard: Current leaderboard data. In this post, we will not consider them.

7.1 Naive client state

Naive implementation getCurrentState() can only directly return the data of the most recently received game update.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}

Nice and clear! But if only it were that simple. One of the reasons this implementation is problematic: it limits the rendering frame rate to the server clock rate.

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

Tick ​​Rate: The frequency at which the server sends game updates to clients. It is often lower than the frame rate. In our game, the server runs at a frequency of 30 cycles per second.

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

Creating a Multiplayer .io Web Game
Unfortunately, nothing is perfect. A more realistic picture would be:
Creating a Multiplayer .io Web Game
The naive implementation is practically the worst case when it comes to latency. If a game update is received with a delay of 50ms, then client stalls an extra 50ms because it's still rendering the game state from the previous update. You can imagine how uncomfortable this is for the player: arbitrary braking will make the game feel jerky and unstable.

7.2 Improved client state

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

Creating a Multiplayer .io Web Game
This gives us a 100ms buffer to survive unpredictable game update times:

Creating a Multiplayer .io Web Game
The payoff for this will be permanent input lag for 100 ms. This is a minor sacrifice for smooth gameplay - most players (especially casual players) will not even notice this delay. It's much easier for people to adjust to a constant 100ms latency than it is to play with an unpredictable latency.

We can also use another technique called client-side prediction, which does a good job of reducing perceived latency, but will not be covered in this post.

Another improvement we are using is linear interpolation. Due to rendering lag, we are usually at least one update ahead of the current time in the client. When called getCurrentState(), we can execute linear interpolation between game updates just before and after the current time in the client:

Creating a Multiplayer .io Web Game
This solves the frame rate issue: we can now render unique frames at any frame rate we want!

7.3 Implementing enhanced client state

Implementation example in src/client/state.js uses both render lag and linear interpolation, but not for long. Let's break the code into two parts. Here is the first one:

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 only one game update before the current server time
  const base = getBaseUpdate();
  if (base > 0) {
    gameUpdates.splice(0, base);
  }
}

function currentServerTime() {
  return firstServerTimestamp + (Date.now() - gameStart) - RENDER_DELAY;
}

// Returns the index of the base update, the first game update before
// 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 <= serverTime) {
      return i;
    }
  }
  return -1;
}

The first step is to figure out what currentServerTime(). As we saw earlier, every game update includes a server timestamp. We want to use render latency to render the image 100ms behind the server, but we will never know the current time on the server, because we can't know how long it took for any of the updates to get to us. The Internet is unpredictable and its speed can vary greatly!

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

Oh wait. Shouldn't it be server time = client time? Why do we distinguish between "server timestamp" and "client timestamp"? This is a great question! It turns out they are not the same thing. Date.now() will return different timestamps in the client and server, and it depends on factors local to these machines. Never assume that timestamps will be the same on all machines.

Now we understand what does currentServerTime(): it returns the server timestamp of the current render time. In other words, this is the server's current time (firstServerTimestamp <+ (Date.now() - gameStart)) minus render delay (RENDER_DELAY).

Now let's take a look at how we handle game updates. When received from the update server, it is called processGameUpdate()and we save the new update to an array gameUpdates. Then, to check the memory usage, we remove all the old updates before base updatebecause we don't need them anymore.

What is a "basic update"? This the first update we find by moving backwards from the server's current time. Remember this diagram?

Creating a Multiplayer .io Web Game
The game update directly to the left of "Client Render Time" is the base update.

What is the base update used for? Why can we drop updates to baseline? To figure this out, let's at last consider the implementation getCurrentState():

state.js part 2

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

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

  // If base is the most recent update we have, use its state.
  // Else, interpolate between its state and the state of (base + 1).
  if (base < 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 < 0 means that there are no updates until the current render time (see above implementation getBaseUpdate()). This can happen right at the start of the game due to rendering lag. In this case, we use the latest update received.
  2. base is the latest update we have. This may be due to network delay or poor Internet connection. In this case, we are also using the latest update we have.
  3. We have an update both before and after the current render time, so we can interpolate!

All that's left in state.js is an implementation of linear interpolation that is simple (but boring) math. If you want to explore it yourself, then open state.js on Github.

Part 2. Backend server

In this part, we'll take a look at the Node.js backend that controls our .io game example.

1. Server Entry Point

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

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 Webpack? This is where we will use our Webpack configurations. We will use them in two ways:

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

Another important task server.js is to set up the server socket.iowhich just 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 => {
  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 establishing a socket.io connection to the server, we set up event handlers for the new socket. Event handlers handle messages received from clients by delegating to a 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 are creating an .io game, so we only need one copy Game ("Game") - all players play in the same arena! In the next section, we will see how this class works. Game.

2. Game servers

Class Game contains the most important logic on the server side. It has two main tasks: player management ΠΈ game simulation.

Let's start with the first task, player management.

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 the players by the field id their socket.io socket (if you get confused, then go back to server.js). Socket.io itself assigns each socket a unique idso we don't need to worry about that. I will call him Player ID.

With that in mind, let's explore instance variables in a class Game:

  • sockets is an object that binds the player ID to the socket that is associated with the player. It allows us to access sockets by their player IDs in a constant time.
  • players is an object that binds the player ID to the code>Player object

bullets is an array of objects Bullet, which has no definite order.
lastUpdateTime is the timestamp of the last time the game was updated. We'll see how it's used shortly.
shouldSendUpdate is an auxiliary variable. We will also see its use shortly.
Methods addPlayer(), removePlayer() ΠΈ handleInput() no need to explain, they are used in server.js. If you need to refresh your memory, go back a little higher.

Last line constructor() launches update cycle games (with a frequency of 60 updates / s):

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 => {
      if (bullet.update(dt)) {
        // Destroy this bullet
        bulletsToRemove.push(bullet);
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !bulletsToRemove.includes(bullet),
    );

    // Update each player
    Object.keys(this.sockets).forEach(playerID => {
      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 => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

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

    // Send a game update to each player every other time
    if (this.shouldSendUpdate) {
      const leaderboard = this.getLeaderboard();
      Object.keys(this.sockets).forEach(playerID => {
        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;
    }
  }

  // ...
}

Method update() contains perhaps the most important piece of server-side logic. Here's what it does, in order:

  1. Calculates how long dt passed since the last update().
  2. Refreshes each projectile and destroys them if necessary. We will see the implementation of this functionality later. For now, it is enough for us to know that bullet.update() returns trueif the projectile should be destroyed (he stepped out of the arena).
  3. Updates each player and creates a projectile if necessary. We will also see this implementation later βˆ’ player.update() can return an object Bullet.
  4. Checks for collisions between projectiles and players with applyCollisions(), which returns an array of projectiles that hit players. For each projectile returned, we increase the points of the player who fired it (using player.onDealtDamage()) and then remove the projectile from the array bullets.
  5. Notifies and destroys all killed players.
  6. Sends a game update to all players every second times when called update(). This helps us keep track of the auxiliary variable mentioned above. shouldSendUpdate... Because update() called 60 times/s, we send game updates 30 times/s. Thus, clock frequency server clock is 30 clocks/s (we talked about clock rates in the first part).

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

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

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

game.js part 3

class Game {
  // ...

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

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

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

getLeaderboard() pretty simple - it sorts the players by score, takes the top five, and returns the username and score for each.

createUpdate() used in update() to create game updates that are distributed to players. Its main task is to call methods serializeForUpdate()implemented for classes Player ΠΈ Bullet. Note that it only passes data to each player about nearest players and projectiles - there is no need to transmit information about game objects that are far from the player!

3. Game objects on the server

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

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 anchor point for the extension. Let's see how the class Bullet 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 < 0 || this.x > Constants.MAP_SIZE || this.y < 0 || this.y > Constants.MAP_SIZE;
  }
}

implementation Bullet very short! We have added to Object only the following extensions:

  • Using the package shortid for random generation id shell.
  • Adding a field parentIDso that you can track the player who created this projectile.
  • Adding a return value to update()which is equal to trueif the projectile is outside the arena (remember we talked about this in the last section?).

Let's move on 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 <= 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 more complex than projectiles, so a few more fields should be stored in this class. His method update() does a lot of work, in particular, returns the newly created projectile if there are none left fireCooldown (remember we talked about this in the previous section?). Also it extends the method serializeForUpdate(), because we need to include additional fields for the player in the game update.

Having a base class Object - an important step to avoid repeating code. For example, no class Object every game object must have the same implementation distanceTo(), and copy-pasting all these implementations across multiple files would be a nightmare. This becomes especially important for large projects.when the number of expanding Object classes are growing.

4. Collision detection

The only thing left for us is to recognize when the projectiles hit the players! Remember this piece of code from the method update() in class Game:

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 => {
      if (this.players[b.parentID]) {
        this.players[b.parentID].onDealtDamage();
      }
    });
    this.bullets = this.bullets.filter(
      bullet => !destroyedBullets.includes(bullet),
    );

    // ...
  }
}

We need to implement the method applyCollisions(), which returns all projectiles that hit players. Luckily, it's not that hard to do because

  • All colliding objects are circles, which is the simplest shape to implement collision detection.
  • We already have a method distanceTo(), which we implemented in the previous section in the class Object.

Here is what 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 < bullets.length; i++) {
    // Look for a player (who didn't create the bullet) to collide each bullet with.
    // As soon as we find one, break out of the loop to prevent double counting a bullet.
    for (let j = 0; j < players.length; j++) {
      const bullet = bullets[i];
      const player = players[j];
      if (
        bullet.parentID !== player.id &&
        player.distanceTo(bullet) <= 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. Here is the case where the distance between the centers of two circles is exactly equal to the sum of their radii:

Creating a Multiplayer .io Web Game
There are a couple more aspects to consider here:

  • The projectile must not hit the player who created it. This can be achieved by comparing bullet.parentID с player.id.
  • The projectile must only hit once in the limiting case of multiple players colliding at the same time. We will solve this problem using the operator break: as soon as the player colliding with the projectile is found, we stop the search and move on to the next projectile.

the end

That's all! We've covered everything you need to know to create an .io web game. What's next? Build your own .io game!

All sample code is open source and posted on Github.

Source: habr.com

Add a comment