⬆️ ⬇️

Create a multiplayer web game in the genre .io

image


Released in 2015, Agar.io became the progenitor of the new genre of .io games , whose popularity has greatly increased since then. I experienced the growing popularity of .io games: in the last three years I have 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 genre .io: Slither.io and Diep.io.



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



Sample .io game



To help you learn, we will refer to the example of the .io game . Try to play it!

')



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



1. Overview / Project Structure



I recommend downloading the source code of the sample game so that you can follow me.


The example uses the following:





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. public/assets/ contains images used by our project.



src /



All source code is in the src/ folder. The names 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, we use the Webpack module manager to build the project. 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 are the following lines:





You may have noticed the strange names of the package files '[name].[contenthash].ext' . They contain substitutions for Webpack file names : [name] will be replaced with the name of the input point (in our case it is a game ), and [contenthash] will be replaced with a 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 the package changes, its file name also changes (the contenthash changes). The finished result is the file name of the game.dbeee76e91a97d0c7207.js file.



The webpack.common.js file is the base configuration file that we import into the development and finished 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 webpack.dev.js , and switch to webpack.prod.js to optimize the size of the packages when deployed to production.



Local setting



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 must be installed on the system. Next you need to run



 $ 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 web browser on 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. Customer entry points



Let's get to the game code itself. For a start, 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


  <! DOCTYPE html>
 <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> 


This code sample is slightly simplified for clarity, I will do the same with many other post examples. The full code can always be viewed on Github .



We have:





After loading the home page in the browser, Javascript code will start to run, starting with 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(() => { playMenu.classList.remove('hidden'); usernameInput.focus(); playButton.onclick = () => { // 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 needed to render the game.
  4. After completing step 3 , the main menu ( playMenu ) is playMenu .
  5. Configuring the "PLAY" button click handler. 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 index.js file. Now we consider them all in order.



4. Client data exchange



In this game, we use the well-known library socket.io to communicate with the server. Socket.io has built-in support for WebSockets , which 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 handle all communications 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 is also slightly shortened for clarity.



There are three main actions in this file:





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 => { 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 so difficult to implement! The main point is to keep the assets object, which will bind the key of the file name to the value of the Image object. When the resource is loaded, we save it in the assets object for a quick receipt in the future. When downloading of each individual resource will be allowed (that is, all resources will be downloaded), we allow downloadPromise .



After downloading the resources, you can start rendering. As mentioned earlier, we use HTML5 Canvas ( <canvas> ) for drawing on a web page. 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 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 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, ); } 


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



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


6. Client input



It is 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 this will be 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 that call updateDirection() (from networking.js ) when an input event occurs (for example, when you move the mouse). updateDirection() handles messaging with a server that processes an 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 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 provide us with the current state of the game in the client at any time based on the 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:





7.1 Naive client state



The naive implementation of getCurrentState() can only directly return the data of the latest 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; } 


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



Frame Rate : The number of frames (i.e. render() calls) 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 is lower than the frame rate . In our game, the server operates at a frequency of 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 a second, half of these calls will simply redraw the same thing, in essence, without doing anything. Another problem with the naive implementation is that it is subject to delays . With the 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 a game update is received with a delay of 50 ms, the client is braked for an extra 50 ms, because it still renders the game state 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 client status



We will make some improvements to the naive implementation. First, we use a rendering delay of 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 render the state in which the server was at time 50 :





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





The cost of this will be a constant input delay (input lag) 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 the constant delay of 100 ms, than to play with an unpredictable delay.



We can use another technique called “client side prediction,” which does a good job of reducing perceived delays, but it will not be considered in this post.


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 game updates just 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 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() 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 in firstServerTimestamp and save our local (client) timestamp at the same time in gameStart .



Oh, wait a minute. Shouldn't there be time on the server = time in the client? 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 timestamp of the current rendering time server .In other words, this is the current server time ( firstServerTimestamp <+ (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, it is invoked processGameUpdate(), and we save the new update to an array gameUpdates. Then, to check the memory usage, we delete all the old updates to the base 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 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 < 0means that up to the current rendering time there are no updates (see implementation above getBaseUpdate()). This can happen immediately at the beginning of the game due to the delay in rendering. In this case, we use the latest update received.
  2. base- This is the latest update that 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 state.jsis a realization of linear interpolation, which is a simple (but boring) mathematics. If you want to study it yourself, then open it state.json Github .



Part 2. Backend server



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



1. Server entry point



To manage the web server, we will use the popular Node.js web framework 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 the Webpack? This is where we will use our Webpack configurations. We will apply them in two ways:





Another important task server.jsis to configure the socket.io server , which 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 => { 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 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 a game of the .io genre, so we need only one copy Game(“Game”) - all players play in the same arena! In the next section, we will look at how this class works Game.



2. Game server



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



Let's start with the first task - control 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 idtheir socket.io socket field (if you mess up, then go back to server.js). Socket.io itself assigns each socket a unique one id, so we don’t need to worry about it. I will call it player ID .



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





bulletsIs an array of objects Bulletthat does not have a specific order.

lastUpdateTime- This is the timestamp of the last update of the game. Soon we will see how it is used.

shouldSendUpdateIs an auxiliary variable. We will see its use soon too.

Methods addPlayer(), removePlayer()and handleInput()no need to explain, they are used in server.js. If you need to refresh your memory, go back a little higher.



The last line constructor()starts the game update cycle (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; } } // ... } 


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



  1. Calculates how much time dthas passed since the last one update().
  2. . . , bullet.update() true , ( ).
  3. . — player.update() Bullet .
  4. applyCollisions() , , . , ( player.onDealtDamage() ), bullets .
  5. .
  6. update() . shouldSendUpdate . update() 60 /, 30 /. , 30 / ( ).


? . 30 – !


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


The rest of the class Gameconsists 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()it's pretty simple - it sorts the players by the number of points, takes the top five and returns the username and score for each.



createUpdate()used update()to create game updates that are passed to players. Its main task is to call methods serializeForUpdate()implemented for classes Playerand Bullet. Note that it only transfers data to each player about the 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 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 reference point for expansion. Let's see how the class Bulletuses 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; } } 


The implementation is Bulletvery short! We added to Objectonly the following extensions:





Let's go 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 harder than shells, so several more fields should be stored in this class. His method update()does a great job, in particular, returns the newly created projectile, if not left fireCooldown(remember, we talked about this in the previous section?). It also extends the method serializeForUpdate()because we need to include additional fields in the game update for the player.



Having a base class Objectis an important step to avoid code recurrence . For example, without a class, Objecteach game object should have the same implementation distanceTo(), 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 Objectclasses grows.



4. Collision Detection



The only thing left for us is to recognize when the shells hit the players! Recall this code snippet from a method update()in a 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 a method applyCollisions()that returns all the shells that hit the players. Fortunately, it is not so difficult to do, because





Here is our implementation of collision recognition:



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 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 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 of the example code is open source and uploaded on Github .

Source: https://habr.com/ru/post/450574/



All Articles