this
and Promises . Even if you know Javascript is not perfect, you can still figure out most of the post.
I recommend downloading the source code of the sample game so that you can follow me.
public/ assets/ ... src/ client/ css/ ... html/ index.html index.js ... server/ server.js ... shared/ constants.js
public/
folder will be statically transmitted by the server. public/assets/
contains images used by our project.
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.
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', }), ], };
src/client/index.js
is the entry point of the Javascript client (JS). Webpack will start from here and recursively look for other imported files.dist/
directory. I will call this file our js package .'[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.
webpack.common.js
file is the base configuration file that we import into the development and finished project configurations. Here, for example, development configuration:
const merge = require('webpack-merge'); const common = require('./webpack.common.js'); module.exports = merge(common, { mode: 'development', });
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.
$ git clone https://github.com/vzhou842/example-.io-game.git $ cd example-.io-game $ npm install
$ npm run develop
index.html
page, when visiting the site the browser will load it first. Our page will be pretty simple:
<! 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>
<canvas>
) that we will use to render the game.<link>
to add our CSS package.<script>
to add our javascript package.<input>
and button “PLAY” ( <button>
).src/client/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); }; });
connect()
to establish a connection with the server and launch downloadAssets()
to download the images needed to render the game.playMenu
) is playMenu
.index.js
file. Now we consider them all in order.
src/client/networking.js
file that will handle all communications with the server:
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); };
connectedPromise
allowed only when we have established a connection.processGameUpdate()
and onGameOver()
) for messages that we can receive from the server.play()
and updateDirection()
so that other files can use them. 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];
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
.
<canvas>
) for drawing on a web page. Our game is pretty simple, so we only need to draw the following:
src/client/render.js
that draw exactly the four points listed above:
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); }
render()
is the main function of this file. startRendering()
and stopRendering()
control the activation of the render cycle at 60 FPS.
renderBullet()
) are not so important, but here is one simple example:
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, ); }
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 .
src/client/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.
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.
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 } ] }
getCurrentState()
can only directly return the data of the latest received game update.
let lastGameUpdate = null; // Handle a newly received game update. export function processGameUpdate(update) { lastGameUpdate = update; } export function getCurrentState() { return lastGameUpdate; }
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.
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):
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.
getCurrentState()
is called, we can perform linear interpolation between game updates just before and after the current time in the client:
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:
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; }
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!
firstServerTimestamp
and save our local (client) timestamp at the same time in gameStart
.
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.
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
).
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.
getCurrentState()
:
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), }; } }
base < 0
means 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.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.state.js
is a realization of linear interpolation, which is a simple (but boring) mathematics. If you want to study it yourself, then open it state.js
on Github .
src/server/server.js
:
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}`);
dist/
in which Webpack will write our files after the production build.server.js
is to configure the socket.io server , which simply connects to the Express server:
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); });
game
:
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); }
Game
(“Game”) - all players play in the same arena! In the next section, we will look at how this class works Game
.
Game
contains the most important server-side logic. It has two main tasks: player control and game simulation .
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); } } // ... }
id
their 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 .
Game
:
sockets
- this is the object that binds the player ID to the socket that is associated with the player. It allows us to get access to sockets by their player IDs for a constant time.players
- this is the object that binds the player ID to the object code> Playerbullets
Is an array of objects Bullet
that 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.
shouldSendUpdate
Is an auxiliary variable. We will see its use soon too.
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.
constructor()
starts the game update cycle (with a frequency of 60 updates / s):
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; } } // ... }
update()
probably contains the most important part of server-side logic. In order we list everything that he does:
dt
has passed since the last one update()
.bullet.update()
true
, ( ).player.update()
Bullet
.applyCollisions()
, , . , ( player.onDealtDamage()
), bullets
.update()
. shouldSendUpdate
. update()
60 /, 30 /. , 30 / ( ).? . 30 – !
Why then just not callupdate()
30 times a second? To improve the simulation of the game. The more often it is calledupdate()
, the more accurate the game simulation will be. But do not get too carried away with the number of callsupdate()
, because this is a computationally expensive task - 60 per second is quite enough.
Game
consists of helper methods used in update()
:
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 Player
and 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!
Object
:
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, }; } }
Bullet
uses Object
:
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; } }
Bullet
very short! We added to Object
only the following extensions:
id
projectile generation .parentID
so that you can track the player who created this projectile.update()
, which is equal true
if the projectile is outside the arena (remember, we talked about this in the last section?).Player
:
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, }; } }
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.
Object
is an important step to avoid code recurrence . For example, without a class, Object
each 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 Object
classes grows.
update()
in a class Game
:
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), ); // ... } }
applyCollisions()
that returns all the shells that hit the players. Fortunately, it is not so difficult to do, because
distanceTo()
that we implemented in a class in the previous section Object
. 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; }
bullet.parentID
with player.id
.break
: as soon as a player is found that faces a projectile, we stop searching and proceed to the next projectile.Source: https://habr.com/ru/post/450574/