Building an MMO browser game with Socket.IO — Part 1: Foundation

Setting up the foundations for the creation of an MMO browser game with Socket.IO & Express. Follow up after the general introduction: ‘Creating a simple browser chat application with Socket.IO

Photo by Vlad Sargu on Unsplash

Introduction

This article is pretty long, since I wanted to make an interesting foundation to an MMO browser game: supporting multiple users and seeing them move across the screen synchronized on every connected client.

The MMO will be set up with Socket.IO, a NodeJS server and HTML5 frontend. No fancy canvas rendering.

The code examples in this article are written in ES6. Features used, among others, include object destructuring, the ternary operator and template literals. It’s also quite useful to know a bit of NodeJS & Express

In this article I’ll take you through the server setup and the emitting of events with Socket.IO and reacting to them. We’ll utilize this to synchronize movement of users across all clients connected to our game. Enjoy!

Project initialization

We’ll quickly setup a NodeJS Express server. We’ll create a folder/repository, init NPM and add the packages express and socket.io.

In our repository, we’ll create an index.js file and a static folder with an index.html, style.css and app.js file. The folder structure so far should look like this:

/index.js
/static/
/ -- /index.html
/ -- /style.css
/ -- /app.js

Link the files together, and add <script src="socket.io/socket.io.js"></script> to the bottom of the index.html file. Detailed look in my previous article about Socket.IO.

In our server file, we’ll setup the server to work with Socket.IO. Notice the creation of the server variable with http.createServer() and server.listen(). these are necessary for Socket.IO to work.

// index.js (server)const express = require('express')
const app = express()
const server = require('http').createServer(app)
const path = require('path')
const io = require('socket.io')(server)
app.use(express.static(path.join(__dirname, '/static')))io.on('connection', socket => {
console.log('some client connected');
})
const port = process.env.PORT || 3000server.listen(port, () => {
console.log('listening on: *', port);
})

In our app.js file (clientside javascript file) we’ll initialize the Socket.IO connection:

// static/app.js (client)const socket = io()

Run the server by executing node index.js (or use nodemon). The console your server is running in should print the following when browsing to localhost:3000 in your browser:

Server start message and our own Socket.IO connection message

In our index.html file we’ll create a div which will serve as the game window.

// static/index.html (client)<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>HTML5 MMo browser game</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="canvas"></div>
<script src="socket.io/socket.io.js"></script>
<script src="app.js" type="module"></script>
</body>
</html>

We’ll add a folder called ‘modules’ in our static folder and add a canvas.js file to handle the game window. The folder structure now looks like this:

/index.js
/static/
/ -- /index.html
/ -- /style.css
/ -- /app.js
/ -- /modules/
/ -- / -- /canvas.js

In the canvas file we’ll add a canvas class, and export an instance of that.

// static/modules/canvas.js (client)class Canvas {
constructor() {
this.element = document.querySelector('#canvas')
// store dimensions to be used for entity positioning reference
this.dimensions = this.element.getBoundingClientRect()
}
}
const canvas = new Canvas()export default canvas

We will want to add game elements via this class, so we’ll write a create method that will take an element type and an object with the attributes it should have (like css class, id and more).

// static/modules/canvas.js (client)class Canvas {
constructor() {
this.element = document.querySelector('#canvas')
}
create(type, props) {
const node = document.createElement(type)
// make html element
// loop through all properties in the props object and seperate it in attr name and value pairs.
Object.entries(props).forEach(([attr, value]) => {
// if array of values for attribute is passed, make one string out of them.
if (Array.isArray(value)) {
value = value.join(' ')
}
node.setAttribute(attr, value)
// set attribute's value
})
}

}
const canvas = new Canvas()export default canvas

Now an element will be created. Example usage would be:

canvas.create(‘div’, {‘class’: [‘player’, ‘user’]})// expected output:
<div class="player user"></div>

Now we need to link the freshly created HTML node to the canvas element. Also note that we want to return the HTML element in case we want to save a reference to that element on the location we called the create method:

// static/modules/canvas.js (client)class Canvas {
constructor() {
this.element = document.querySelector('#canvas')
}
add(node) {
this.element.appendChild(node)
}

create(type, props) {
const node = document.createElement(type)
Object.entries(props).forEach(([attr, value]) => {
if (Array.isArray(value)) {
value = value.join(' ')
}
node.setAttribute(attr, value)
})
this.add(node)
return node
}
}
const canvas = new Canvas()export default canvas

If we now create an element it will be appended to the canvas div:

canvas.create(‘div’, {‘class’: [‘player’, ‘user’]})// expected output in div with id canvas:
<div class="player user"></div>

Add some styles to style.css to make the canvas visible. We’ll be using a 32x32 grid, so make the width and height a multitude of that.

// static/style.css#canvas {
height: calc(20*32px);
width: calc(35*32px);
content: ""; /* make canvas visible at all times */
background-color: #505050;
margin: 0 auto;
}
The first rendering of our game!

Creating a user

Our game will feature multiple Users. We will call the User we control the player. Other Users roaming around our MMO game will be called users.
We’ll quickly add a basic Player class to render a player on the screen. Add player.js to your static modules folder.

Create a Player class and import the canvas module into the file. Now in the Player constructor create an element to render as the player element via canvas.create().

// static/modules/player.js (client)import canvas from './canvas.js'class Player {
constructor() {

// create element for player and save reference to it
this.element = canvas.create('div', {'class': ['player', 'user']})
}
}
const player = new Player()export default player

Import the player into your app.js file:

// static/app.js (client)import player from './modules/player.js'const socket = io()

Add some styles for the player element:

// static/style.css#canvas {
height: calc(21 * 32px);
width: calc(35 * 32px);
background-color: #505050;
content: "";
margin: 0 auto;
position: relative;
overflow: hidden;
}
.user {
position: absolute;
top: calc(10 * 32px);
/* position player in center of canvas */
left: calc(17 * 32px);
/* position player in center of canvas */
width: 32px;
height: 32px;
border-radius: 50%;
background: #FFF;
content: "";
}

If you browse to localhost:3000 your player will show up in the canvas:

We have a player now!

Movement

A static game is boring: we want to make sure the player can move around in the canvas. Our movement logic for now should adhere to two rules:

  • A player must be able to move around
  • A player must not be able to walk out of view

Seems simple enough! Let’s add a method to the player class that moves the player element 32 pixels in any given direction. We first have to store a reference to the current position of the Player in the 32x32 grid:

// static/modules/player.js (client)import canvas from './canvas.js'class Player {
constructor() {
this.element = canvas.create('div', {'class': ['player', 'user']})

this.coordinates = {
x: 0,
y: 0,
}
this.maxCoordinates: {
// set max coordinates to half of possible steps in any direction per axis (minus one because in this case our grid is uneven)
x: (canvas.dimensions.width / 32 - 1) / 2

y: (canvas.dimensions.height / 32 - 1) / 2
}

}
}
const player = new Player()export default player

Now for the move method. A player can move on both the x and y axis, and the negative and positive values of those two. This means our move method will accept an object with the axis to move on, and in which direction. Movement will always be done in steps of one, multiplied by 32 pixels.

First, we have to listen to the keydown event of the mouse keys (or w,a,s,d keys, whatever you prefer!). We’ll add a listener in a method on the Player class. It listens at the window, and fires our yet-to-be-created move method at specific keycodes.

// static/modules/player.js (client)import canvas from './canvas.js'class Player {
constructor() {
this.element = canvas.create('div', {'class': ['player', 'user']})

this.coordinates = {
x: 0,
y: 0,
}
this.maxCoordinates: {
x: (canvas.dimensions.width / 32 - 1) / 2
y: (canvas.dimensions.width / 32 - 1) / 2
}
this.initMovement()

}
initMovement() {
window.addEventListener('keydown', e => {
if (e.keyCode === 38) {
// up
this.move('y', -1)
}
if (e.keyCode === 40) {
// down
this.move('y', 1)
}
if (e.keyCode === 37) {
// left
this.move('x', -1)
}
if (e.keyCode === 39) {
// right
this.move('x', 1)
}
})
}

}
const player = new Player()export default player

Now that we have a listener, we still have to make the move method to actually move the player element.

We’ll need to check which axis is being translated on. On the axis that is being translated on, we have to add the direction (can only be 1 or -1) to the already known coordinate.

// static/modules/player.js (client)class Player {
(...)
move(axis, direction) {
// if axis is x, add direction to known coordinate, otherwise keep current coordinate
const x = axis === 'x'
? this.coordinates.x + direction
: this.coordinates.x
const y = axis === 'y'
? this.coordinates.y + direction
: this.coordinates.y

this.setPosition({x, y})
}
setPosition({x, y}) {
// don't forget to multiply the coordinate by 32 for correct position in pixels!
this.element.style = `transform: translate(${x * 32}px, ${y * 32}px);`
}

}
const player = new Player()export default player

Now we can move, but only one tile from our original position. After the movement is done, we need to update the current coordinates.

// static/modules/player.js (client)class Player {
(...)
move(axis, direction) {
const x = axis === 'x'
? this.coordinates.x + direction
: this.coordinates.x
const y = axis === 'y'
? this.coordinates.y + direction
: this.coordinates.y
this.setPosition({x, y})
}
setPosition({x, y}) {
this.element.style = `transform: translate(${xAxisSteps * 32}px, ${yAxisSteps * 32}px);`
this.coordinates = {x, y}
}
(...)
}
const player = new Player()export default player

Great! We can move now. We still need to tackle problem two though:

A player must not be able to walk out of view

Well, we already added the max possible steps in a certain direction to our player coordinates object. We can call them from this.maxCoordinates.x and this.maxCoordinates.y.

We’ll write a simple method that returns a check whether the proposed next move exceeds those values. If it does, we will cancel the move method.

// static/modules/player.js (client)import canvas from './canvas.js'class Player {
(...)
isMoveAllowed(axis, direction) {
return this.coordinates[axis] + direction
<= this.maxCoordinates[axis]
// check negative direction:
&& this.coordinates[axis] + direction
>= this.maxCoordinates[axis] * -1
}

move(axis, direction) {
if (!this.isMoveAllowed(axis, direction)) return
const x = axis === 'x'
? this.coordinates.x + direction
: this.coordinates.x
const y = axis === 'y'
? this.coordinates.y + direction
: this.coordinates.y
this.setPosition({x, y})
}
(...)
}
const player = new Player()export default player
Our player can’t move past the borders of the canvas div!

Multiple users

It’s great to move around in our little game window, but we’re creating an MMO after all.
Let’s build some support for multiple users.
It’s smart game design for MMO games to keep one source of truth: one place that decides who is where and what is going on. In this case, the best place is the server.
We’ll let the server know about newly joined users, so then the server can broadcast to all clients that a new user connected. Then the clients can render an element to reflect that event.

To achieve this, we’ll emit an event via Socket.IO to the server when the Player renders on our screen.

// static/app.js (client)import player from './modules/player.js'const socket = io()socket.emit('user-connected')

In our server file we’ll listen to that event, and send the socket id to all other clients to render a new, unique user.

// index.js (server)const express = require('express')
const app = express()
const server = require('http').createServer(app)
const path = require('path')
const io = require('socket.io')(server)
app.use(express.static(path.join(__dirname, '/static')))io.on('connection', socket => {
socket.on('user-connected', () => {
io.emit('user-connected', socket.id)
})

})
const port = process.env.PORT || 3000server.listen(port, () => {
console.log('listening on: *', port);
})

Then on the client again, we want to listen to that broadcasted event. We want to render a new element on the screen if a user connects that’s not ourselves. So when a user connects, we need to check if the id that’s being sent with the broadcast doesn’t match our own socket id. If it’s not the same, we know it’s a different user.

// static/app.js (client)import player from './modules/player.js'const socket = io()socket.emit('user-connected')socket.on('user-connected', id => {
if (id === socket.id) return
// render user
})

Now that we have the connection in place, let’s refactor some of our code. We have a player class, but most functionality of that class will also be used for the other users we render on the screen.

Let’s create a user module in our modules map:

// static/modules/user.js (client)class User {
constructor() {
}
}
export default User

Now let’s take the entire Player class except for the initMovement method and copy it into the User class:

// static/modules/user.js (client)import canvas from './canvas.js'class User {
constructor() {
this.element = canvas.create('div', {'class': 'user'})
this.coordinates = {
x: 0,
y: 0,
}
this.maxCoordinates: {
x: (canvas.dimensions.width / 32 - 1) / 2,
y: (canvas.dimensions.height / 32 - 1) / 2,
}
}
isMoveAllowed(axis, direction) {
return this.coordinates[axis] + direction
<= this.maxCoordinates[axis]
&& this.coordinates[axis] + direction
>= this.maxCoordinates[axis] * -1
}
move(axis, direction) {
if (!this.isMoveAllowed(axis, direction)) return
const x = axis === 'x'
? this.coordinates.x + direction
: this.coordinates.x
const y = axis === 'y'
? this.coordinates.y + direction
: this.coordinates.y
this.setPosition({x, y})
}
setPosition({x, y}) {
this.element.style = `transform: translate(${x * 32}px, ${y * 32}px);`
this.coordinates = {x, y}
}
}
export default User

And rewrite the Player class to the following:

// static/modules/player.js (client)import canvas from './canvas.js'
import User from './user.js'
class Player extends User {
constructor() {
super()
this.element = canvas.create('div', {'class': ['player', 'user']})
this.initMovement()
}
initMovement() {
window.addEventListener('keydown', e => {
if (e.keyCode === 38) {
this.move('y', -1)
}
if (e.keyCode === 40) {
this.move('y', 1)
}
if (e.keyCode === 37) {
this.move('x', -1)
}
if (e.keyCode === 39) {
this.move('x', 1)
}
})
}
}
const player = new Player()export default player

Let’s create a new User every time a new user connects to the game:

// static/app.js (client)import player from './modules/player.js'
import User from './modules/user.js'
const socket = io()socket.emit('user-connected')socket.on('user-connected', id => {
if (id === socket.id) return
const user = new User()
})

And distinguish our player from other users by adding some styling to the player css class:

// static/style.css#canvas {
width: calc(35 * 32px);
height: calc(19 * 32px);
background-color: #505050;
content: "";
margin: 0 auto;
position: relative;
overflow: hidden;
}
.user {
position: absolute;
left: calc(17 * 32px);
top: calc(9 * 32px);
width: 32px;
height: 32px;
border-radius: 50%;
background: #FFF;
content: "";
}
.player {
background: cyan;
}

Open two tabs and navigate both of them to localhost:3000

You can see multiple users now!

Keeping data consistent

Well, we have multiple users now. It’s probably a good idea to keep track of the users in some way. What better place to do that than our one source of truth: The server.

Let’s add some functionality to our user-connect listener on the server and push the newly connected user’s socket id to a user array.
Then, when a user disconnects from the game we would want to remove that user from the array as well. We’ll also broadcast that to the clients to remove the user’s rendered element from the screen.

// index.js (server)const express = require('express')
const app = express()
const server = require('http').createServer(app)
const path = require('path')
const io = require('socket.io')(server)
app.use(express.static(path.join(__dirname, '/static')))const users = []const getIndex = id => {
return users.findIndex(user => user.id === id)
}
io.on('connection', socket => {
socket.on('user-connected', () => {
users.push({id: socket.id})
io.emit('user-connected', socket.id)
})
socket.on('disconnect', () => {
const index = getIndex(socket.id)
users.splice(1, index)
io.emit('user-disconnected', socket.id)
})

})
const port = process.env.PORT || 3000server.listen(port, () => {
console.log('listening on: *', port);
})

On the client side, we’ll pass the socket id to the new User that is created on user-connect. This way we can link it to the related user element to later be able to delete it. We’ll also store the reference to that user in a users array.

// static/app.js (client)import player from './modules/player.js'
import User from './modules/user.js'
const users = []const socket = io()socket.emit('user-connected')socket.on('user-connected', id => {
if (id === socket.id) return
const user = new User(id)
users.push({id, instance: user})
})

In the User class, we’ll add it to the class instance element properties:

// static/modules/user.js (client)import canvas from './canvas.js'class User {
constructor(id) {
if (id) { // check makes sure no user element gets created by the super() call in the Player class constructor
this.element = canvas.create('div', {
'class': 'user',
'data-id': id
})
}
(...)

Now when the client receives the user-disconnected event, we’ll delete the HTML element from the page and remove the class instance from the users array:

// static/app.js (client)import player from './modules/player.js'
import User from './modules/user.js'
const users = []const socket = io()const getIndex = id => {
return users.findIndex(user => user.id === id)
}
socket.emit('user-connected')socket.on('user-connected', id => {
if (id === socket.id) return
const user = new User(id)
users.push({id, instance: user})
})
socket.on('user-disconnected', id => {
document.querySelector(`[data-id="${id}"]`).remove()
const index = getIndex(id)
users.splice(1, index)
})

Now, there’s one thing left to do. When a user connects, other clients see their element appear on screen. The new user however, doesn’t see the already online users.

We can fix this by returning the already online users when a client emits its user-connected event, by sending a callback to the server.

// static/app.js (client)import player from './modules/player.js'
import User from './modules/user.js'
const users = []const socket = io()const getIndex = id => {
return users.findIndex(user => user.id === id)
}
socket.emit('user-connected', onlineUsers => {
onlineUsers.forEach(user => users.push({
id: user.id,
instance: new User(user.id)
}))
}
)
socket.on('user-connected', id => {
if (id === socket.id) return
const user = new User(id)
users.push({id, instance: user})
})
socket.on('user-disconnected', id => {
document.querySelector(`[data-id="${id}"]`).remove()
const index = getIndex(id)
users.splice(1, index)
})

On the server we handle that like this:

// index.js (server)const express = require('express')
const app = express()
const server = require('http').createServer(app)
const path = require('path')
const io = require('socket.io')(server)
app.use(express.static(path.join(__dirname, '/static')))const users = []const getIndex = id => {
return users.findIndex(user => user.id === id)
}
io.on('connection', socket => {
socket.on('user-connected', cb => {
cb(users)
users.push({id: socket.id})
io.emit('user-connected', socket.id)
})
socket.on('disconnect', () => {
const index = getIndex(socket.id)
users.splice(1, index)
io.emit('user-disconnected', socket.id)
})
})
const port = process.env.PORT || 3000server.listen(port, () => {
console.log('listening on: *', port);
})

Handling other users movement

The only problem is we can only see ourselves move, and no-one else. One way to tackle this is to send our Player’s coordinates to the server every time we move.

We’ll add a set method to the Player class, so we can pass the socket to the class from app.js:

// static/modules/player.js (client)import canvas from './canvas.js'
import User from './user.js'
class Player extends User {
constructor() {
super()
this.element = canvas.create('div', {'class': ['player', 'user']})
this.initMovement()
}
set(key, value) {
this[key] = value
}

initMovement() {
window.addEventListener('keydown', e => {
if (e.keyCode === 38) {
this.move('y', -1)
}
if (e.keyCode === 40) {
this.move('y', 1)
}
if (e.keyCode === 37) {
this.move('x', -1)
}
if (e.keyCode === 39) {
this.move('x', 1)
}
})
}
}
const player = new Player()export default player

And we’ll add the socket to the Player class from app.js:

// static/app.js (client)import player from './modules/player.js'
import User from './modules/user.js'
const users = []const socket = io()player.set('socket', socket)const getIndex = id => {
return users.findIndex(user => user.id === id)
}
socket.emit('user-connected', onlineUsers => {
onlineUsers.forEach(user => users.push({
id: user.id,
instance: new User(user.id)
}))
})
socket.on('user-connected', id => {
if (id === socket.id) return
const user = new User(id)
users.push({id, instance: user})
})
socket.on('user-disconnected', id => {
document.querySelector(`[data-id="${id}"]`).remove()
const index = getIndex(id)
users.splice(1, index)
})

And we’ll check in the Player class if the keycode from the keydown event is equal to up, down, left or right. If it is, we’ll emit the current coordinates of the Player.

// static/modules/player.js (client)import canvas from './canvas.js'
import User from './user.js'
class Player extends User {
constructor() {
super()
this.element = canvas.create('div', {'class': ['player', 'user']})
this.initMovement()
}
set(key, value) {
this[key] = value
}
initMovement() {
const keycodes = [37, 38, 39, 40]
window.addEventListener('keydown', e => {
if (e.keyCode === 38) {
this.move('y', -1)
}
if (e.keyCode === 40) {
this.move('y', 1)
}
if (e.keyCode === 37) {
this.move('x', -1)
}
if (e.keyCode === 39) {
this.move('x', 1)
}
if (keycodes.includes(e.keyCode)) {
this.socket.emit('user-move', this.coordinates)
}

})
}
}
const player = new Player()export default player

In the server we’ll listen to this user-move event, find the player it originated from and update its position in the users array to add their current coordinates.

// index.js (server)(...)const users = []const getIndex = id => {
return users.findIndex(user => user.id === id)
}
io.on('connection', socket => {
socket.on('user-connected', cb => {
cb(users)
users.push({id: socket.id})
io.emit('user-connected', socket.id)
})

socket.on('disconnect', () => {
const index = getIndex(socket.id)
users.splice(1, index)
io.emit('user-disconnected', socket.id)
})
socket.on('user-move', coordinates => {
const index = getIndex(socket.id)
users[index].coordinates = coordinates
io.emit('user-move', {id: socket.id, coordinates})
})

})
(...)

Now on the client we’ll listen to this broadcast, find the class instance in the users array and fire it’s setPosition() method to translate the related element to the new coordinates. For consistency’s sake, we’ll also set the coordinates when we fetch all online users.

// static/app.js (client)import player from './modules/player.js'
import User from './modules/user.js'
const users = []const socket = io()player.set('socket', socket)const getIndex = id => {
return users.findIndex(user => user.id === id)
}
socket.emit('user-connected', onlineUsers => {
onlineUsers.forEach(user => {
const id = user.id
const instance = new User(id)

instance.setPosition(user.coordinates)
users.push({
id, instance
})
})
})
(...)socket.on('user-move', user => {
const index = getIndex(user.id)
const instance = users[index].instance
instance.setPosition(user.coordinates)
})
User movement now synchronizes across clients!

Final words

If you are still here, I’m impressed. This article got way longer than I intented, but creating MMO games isn’t the easiest subject to write about. I hope you have found this use for Socket.IO interesting.

I really enjoy watching things move in a different screen than where the instructions to move come from. This makes developing these projects really satisfactory.

If I ever come around to write a next part, it’ll probably handle

  • storing user data in a database
  • user interaction

But for now, this is all. You can rest at the campfire below to regain some mental strength after this long article.

Photo by Kyle Peyton on Unsplash

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store