How to Build a Multiplayer (.io) Web Game, Part 1

Making: github.com/vzhou842/ex…

Explore the Javascript client-side of a.io game in depth.

If you’ve never heard of.io games before: They’re free multiplayer Web games, easy to join (no account required), and often pit many players against each other in one area. Other famous.io games include Slither. IO and diep. IO.

  • Slither. IO: Slither. IO
  • Diep. IO: Diep. IO

In this article, we’ll learn how to build.io games from scratch. All you need is a practical knowledge of Javascript: you should be familiar with ES6 syntax, the this keyword and Promises, things like that. Even if you’re not most familiar with Javascript, you should still be able to read most of this article.

a.ioThe game is the sample

To help us learn, we’ll refer to the example-igame.victorzhou.com.

It’s a very simple game: you control a ship in an arena with other players. Your ship automatically fires bullets, and you try to hit other players with your own bullets while avoiding them.

directory

This is part 1 of a two-part series. We will introduce the following in this article:

  1. Project overview/Structure: High-level view of the project.
  2. Build/Project Setup: Development tools, configuration, and setup.
  3. Client entry: index.html and index.js.
  4. Client Network communication: Communicates with the server.
  5. Client rendering: Download image resources + render the game.
  6. Client input: Let the user actually play the game.
  7. Client status: Handles game updates from the server.

1. Project Overview/structure

I recommend downloading the source code for the sample game so you can read on.

Our example game uses:

  • Express, Node.js’s most popular Web framework, powers its Web servers.
  • Socket. IO, a Websocket library used to communicate between the browser and the server.
  • Webpack, a module packer.

The structure of the project directory is as follows:

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

public/

Our server will statically serve everything in the public/ folder. Public /assets/ contains the image resources used in our project.

src/

All source code is in the SRC/folder. Client/and server/ can easily indicate that shared/ contains a constant file imported by client and server.

2. Build/project setup

As mentioned earlier, we are using the Webpack module wrapper to build our project. Let’s 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',})]};Copy the code
  • src/client/index.jsIs the Javascript (JS) client entry point. Webpack will start from there, recursively looking for other imported files.
  • The JS output of our Webpack build will be placed indist/Directory. I’ll call this file JS bundle.
  • We’re using Babel, in particular@babel/preset-envConfig to compile JS code for older browsers.
  • We are using a plug-in to extract all the CSS referenced in the JS file and bundle it together. I’m going to call it CSS Bundle.

You may have noticed the strange ‘[name].[Contenthash].ext’ bundle filename. They include Webpack filename substitution :[name] will be replaced with the entry point name (this is game), and [Contenthash] will be replaced with the hash of the file’s content. We do this to optimize caching – we can tell the browser to always cache our JS bundle, because if the JS bundle changes, its filename will change too (contenthash will change as well). The end result is a file name, such as: game. Dbeee76e91a97d0c7207. Js.

The webpack.common.js file is the basic configuration file we imported in our development and production configuration. For example, here is the development configuration:

webpack.dev.js

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

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

We used webpack.dev.js for efficiency during development and switched to webpack.prod.js to optimize the package size when we deployed to production.

Local Settings

I recommend installing the project on your local computer so you can follow the rest of this article. Setup is simple: First, make sure Node and NPM are installed. And then,

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

You’re ready to go! To run the development server, just

$ npm run develop
Copy the code

And access localhost:3000 in a web browser. When you edit the code, the development server will automatically rebuild JS and CSS bundles – just refresh to see the changes!

3. The Client portal

Let’s look at the actual game code. First, we need an index.html page, which is the first thing your browser loads when you visit the site. Ours will be very simple:

index.html

<! DOCTYPEhtml>
<html>
<head>
  <title>An example .io game</title>
  <link type="text/css" rel="stylesheet" href="/game.bundle.css">
</head>
<body>
  <canvas id="game-canvas"></canvas>
  <script async src="/game.bundle.js"></script>
  <div id="play-menu" class="hidden">
    <input type="text" id="username-input" placeholder="Username" />
    <button id="play-button">PLAY</button>
  </div>
</body>
</html>
Copy the code

We have:

  • We will use HTML5 Canvas (<canvas>) elements to render the game.
  • <link>Contains our CSS bundle.
  • <script>Contains our Javascript bundle.
  • Main menu, with user name<input> 和 "PLAY" <button>.

Once the home page is loaded into the browser, our Javascript code will start executing, starting with our JS entry 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);
  };
});
Copy the code

It seems complicated, but there’s not really that much going on:

  • Import a bunch of other JS files.
  • Import some CSS (so Webpack knows to include it in our CSS bundle).
  • runconnect()To establish a connection to the server and rundownloadAssets()To download the graphics needed to render the game.
  • Step 3 The main menu is displayed.playMenu).
  • Set up a click handler for the PLAY button. If you click, initialize the game and tell the server we’re ready to play.

The core of the client logic resides in other files imported by index.js. We’ll discuss each of these questions one by one.

4. Client network communication

For this game, we will use the well-known socket. IO library to communicate with the server. Socket. IO includes built-in support for WebSocket, which is perfect for two-way communication: we can send messages to the server, and the server can send messages to us over the same connection.

We’ll have a file SRC /client/networking.js that takes 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://The ${window.location.host}`);
const connectedPromise = new Promise(resolve= > {
  socket.on('connect'.() = > {
    console.log('Connected to server! ');
    resolve();
  });
});

export const connect = onGameOver= > (
  connectedPromise.then(() = > {
    // Register callbackssocket.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);
};
Copy the code

Three main things happen in this file:

  • We are trying to connect to the server. Only after the connection is established,connectedPromiseTo parse.
  • If the connection is successful, we register a callback (processGameUpdate() 和 onGameOver()) messages we may receive from the server.
  • We exportplay() 和 updateDirection()For use with other files.

5. The Client rendering

It’s time to get things on screen!

But before we can do that, we must download all the images (resources) we need. 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];
Copy the code

Managing assets is not hard to achieve! The main idea is to keep an Assets object that maps the filename key to an Image object value. When an asset is downloaded, we save it into assets objects for later retrieval. Finally, once each asset download has been resolved (meaning all assets have been downloaded), we resolve the downloadPromise.

As the resources download, we can continue rendering. As mentioned earlier, we are using HTML5 canvas (

) to draw onto our web page. Our game is very simple, so what we need to draw is:

  1. background
  2. Our player’s ship
  3. Other players in the game
  4. The bullet

This is an important part of SRC /client/render.js, which accurately draws the four things I 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);
}
Copy the code

Render () is the main function of this file. The startRendering() and stopRendering() control the activation of the 60 FPS rendering cycle.

The specific implementation of the various render help functions (such as renderBullet()) is not that important, but here is a 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,); }Copy the code

Notice how we use the getAsset() method we saw earlier in asset.js!

If you are interested in other render helper functions, read the rest of SRC /client/render.js.

6. Enter 🕹ī¸ on the Client

Now it’s time to make the game playable! Our control scheme is very simple: use the mouse (on the desktop) or touch the screen (on the mobile device) to control the direction of movement. To do this, we will register event listeners for Mouse and Touch events.

SRC /client/input.js handles these issues:

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);
}
Copy the code

OnMouseInput () and onTouchInput() are event listeners that call updateDirection() (from networking. UpdateDirection () is responsible for sending messages to the server, which processes the input events and updates the game state accordingly.

7. State of the Client

This section is the most advanced part of the article. Don’t be discouraged if you can’t read it all at once! Feel free to skip this section and come back to it later.

The final piece of the puzzle required to complete the client code is state. Remember this code from the “client rendering” section?

render.js

import { getCurrentState } from './state';

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

  // Do the rendering
  // ...
}
Copy the code

GetCurrentState () must be able to give us the current game state of the client at any time based on game updates received from the server. Here’s an example of the game updates the server might 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}}]Copy the code

Each game update has the following 5 fields:

  • T: Creates the server timestamp for this update.
  • Me: Receives updated player information.
  • Others: An array of other players in the same game.
  • Bullets: An array of bullets in the game.
  • Leaderboard: Current leaderboard data.

7.1 Native Client Status

The native implementation of getCurrentState() directly returns data for the most recently received game updates.

naive-state.js

let lastGameUpdate = null;

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

export function getCurrentState() {
  return lastGameUpdate;
}
Copy the code

Clean and tidy! If only it were that simple. One of the reasons this implementation is problematic is that it limits the render frame rate to the server tick rate.

  • Frame Rate: number of frames per second (i.e.,render()Call) or FPS. Games are usually aimed at at least 60 FPS.
  • Tick Rate: Rate at which the server sends game updates to the client. This is usually lower than the frame rate. For our game, the server was running at 30 ticks per second.

If we only provide the latest game updates, our effective FPS cannot exceed 30 because we will never receive more than 30 updates per second from the server. Even if we called Render () 60 times per second, half of those calls would just redraw the exact same thing and do nothing.

Another problem with native implementations is that they tend to lag. Under perfect Internet conditions, the client would receive game updates exactly every 33 milliseconds (30 per second) :

Sadly, nothing could be more perfect. A more realistic representation might look like this:

Native implementations are pretty much the worst case scenario when it comes to latency. If the game update is 50 milliseconds late, the client freezes for an additional 50 milliseconds because it is still rendering the game state of the previous update. As you can imagine, this is a terrible experience for the player: the game feels uneasy and unstable due to random freezes.

7.2 Better client status

We will make some simple improvements to this simple implementation. The first uses a rendering delay of 100 milliseconds, which means that the “current” client state is always 100 milliseconds behind the server’s game state. For example, if the server’s time is 150, the client will present the state of the server at time 50:

This gives us a buffer of 100 milliseconds to tolerate unpredictable game updates coming in:

The cost of this is a constant 100 ms input delay. That’s a small price to pay for solid, fluid gameplay — most players (especially casual ones) won’t even notice the lag. It’s much easier for humans to get used to a constant 100-millisecond delay than to try to cope with unpredictable delays.

Another technique called “client-side forecasting” can be used to effectively reduce perceived lag, but this is beyond the scope of this article.

Another improvement we will make is the use of linear interpolation. Due to render delays, we usually update at least 1 time before the current client time. Whenever getCurrentState() is called, we can linearly interpolate between game updates immediately before and after the current client time:

This solved our framerate problem: we could now render unique frames as much as we wanted!

7.3 Achieving Better Client Status

The example implementation in SRC /client/state.js uses render latency and linear interpolation, but it’s a bit long. Let’s break it down into parts. Here’s 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) {
      returni; }}return -1;
}
Copy the code

The first thing to understand is the functionality of currentServerTime(). As mentioned earlier, every game update includes a server timestamp. We want to use render latency to render 100 milliseconds behind the server, but we’ll never know the current time on the server because we don’t know how long any given update will take. The Internet is unpredictable and changes a lot!

To solve this problem, we will use a reasonable approximation: we assume that the first update arrives immediately. If this is true, then we will know the time of the server at that moment! We store the server timestamp in firstServerTimestamp and the local (client) timestamp in gameStart.

Whoa, wait a minute. Shouldn’t the time on the server equal the time on the client? Why is there a difference between “server timestamp” and “client timestamp”? That’s a good question, readers! As it turns out, they’re different. Date.now() will return different timestamps depending on the local factors of the client and server. Never assume that your timestamp is consistent across machines.

It’s now clear what currentServerTime() does: It returns the server timestamp for the current render time. In other words, it is the current server time (firstServerTimestamp + (date.now () -gamestart)) minus render delay (RENDER_DELAY).

Next, let’s look at how to handle game updates. ProcessGameUpdate () is called when an update is received from the server, and we store the new update in the gameUpdates array. Then, to check memory usage, we deleted all the old updates before the base update because we no longer needed them.

What exactly is a basic update? This is the first update we found when we went back in time from the current server. Remember this picture?

The game update to the left of “Client Render time” is a basic update.

What is the purpose of the base update? Why can we discard updates prior to the base update? Finally, let’s look at the implementation of getCurrentState() to find out:

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), }; }}Copy the code

We deal with three cases:

  1. base < 0, meaning no update prior to the current render time (see above)getBaseUpdate()The implementation of. This can happen at the start of the game due to render delays. In this case, we will use the latest update.
  2. baseIs our latest update (đŸ˜ĸ). This condition can be caused by a delayed or poor network connection. In this case, we also use the latest update.
  3. We have updates before and after the current render time, so we can interpolate!

What’s left of state.js is the implementation of linear interpolation, which is just some simple (but boring) math. If you want to check it out, check out state.js on Github.

  • Github.com/vzhou842/ex…
I am for less. Wechat: uuhells123. Public account: Hacker afternoon tea. Thank you for your support 👍👍👍!Copy the code