Creating a Multiplayer Game with WebRTC
Ryan Baxley
Posted on July 10, 2017
My goal with this project was to develop an online multiplayer game which would use as little server resources as possible. I was hoping to be able to run the game server in one users browser and allow the other players to connect to it. I also wanted to keep the project simple enough to be explained in a blog post.
Technology
Pretty soon into my search for Peer to Peer web technology I discovered WebRTC, and it seemed perfect for my project. WebRTC is a new web standard that provides browsers with Real-Time Communication capabilities. Most examples I saw for WebRTC was setting up video or audio streams, but you can also transmit arbitrary data. In my case, I could use the data channel to transmit user input to the host and the game state to the players.
However, WebRTC does not completely eliminate the need for a server. In order to establish a connection, two browsers must exchange a small amount of information. Once the connection is established, the communication is completely peer-to-peer.
Libraries
The WebRTC API is pretty complex, so I looked for a library to simplify it. The most full featured one I came across was PeerJS, but it had not been updated in two years. I quickly ran into some major bugs that forced me to abandon it. I settled upon using simple-peer, which provides a simple API for connecting and communicating using WebRTC. From their documentation:
var SimplePeer = require('simple-peer')
var peer1 = new SimplePeer({ initiator: true })
var peer2 = new SimplePeer()
peer1.on('signal', function (data) {
// when peer1 has signaling data, give it to peer2 somehow
peer2.signal(data)
})
peer2.on('signal', function (data) {
// when peer2 has signaling data, give it to peer1 somehow
peer1.signal(data)
})
peer1.on('connect', function () {
// wait for 'connect' event before using the data channel
peer1.send('hey peer2, how is it going?')
})
peer2.on('data', function (data) {
// got a data channel message
console.log('got a message from peer1: ' + data)
})
Establishing a Connection
In order to establish the connection between two browsers, I needed to exchange about 2 kb of signaling data. I opted to use Firebase Realtime Database, as it allowed me to easily sync data between two browsers, and the free tier offers plenty of storage.
From the users perspective, the host gives the players a four letter code which they use to connect to the game. From the browsers perspective, the process is only slightly more complicated. For reference, my database rules look like this:
{
"rules": {
"rooms": {
// 4 Digit room code used to connect players
"$room_code": {
"host": {
"$player": {
"$data": {
"data": {
// Data from the host for the player
}
}
}
},
"players": {
"$player": {
"$data": {
"data": {
// Data from the player for the host
}
}
}
},
"createdAt": {
// Timestamp set by host when room is created
}
}
}
}
}
Hosting a Room
In order to host a room, the host first generates a code by randomly trying 4 character codes until it finds a room that is not in use. Rooms are considered not in use if they don’t exist in the database, or if the room was created over 30 minutes ago. The host should delete the room when the game starts, but I wanted to be sure to avoid zombie rooms. When the host finds an open room, the host's browser adds itself as the room's host and listens for players.
function getOpenRoom(database){
return new Promise((resolve, reject) => {
const code = generateRoomCode();
const room = database.ref('rooms/'+code);
room.once('value').then((snapshot) => {
const roomData = snapshot.val();
if (roomData == null) {
// Room does not exist
createRoom(room).then(resolve(code));
} else {
const roomTimeout = 1800000; // 30 min
const now = Date.now();
const msSinceCreated = now - roomData.createdAt;
if (msSinceCreated > roomTimeout) {
// It is an old room so wipe it and create a new one
room.remove().then(() => createRoom(room)).then(resolve(code));
} else {
// The room is in use so try a different code
resolve(getOpenRoom(database));
}
}
})
});
}
Joining a Game
A player joins a game by entering the room code and their username. The player's browser notifies the host by adding an entry in the route rooms/[code]/players
. When the player gets their signaling data, the data to the database in the route rooms/[code]/players/[name]
.
// code and name are entered by user
const peer = new SimplePeer({initiator: true});
this.peer = peer;
this.setState({host: peer});
// Sending signaling data from player
peer.on('signal', (signalData) => {
const nameRef = database.ref('/rooms/'+code+'/players/'+name);
const newSignalDataRef = nameRef.push();
newSignalDataRef.set({
data: JSON.stringify(signalData)
});
});
// Listen for signaling data from host for me
const hostSignalRef = database.ref('/rooms/'+code+'/host/'+name);
hostSignalRef.on('child_added', (res) => {
peer.signal(JSON.parse(res.val().data));
});
The host listens for new players being added. When a new player is connected, the host consumes the signals they send and replys with its own signals on the route rooms/[code]/host/[name]
.
// Listen for new players
playersRef.on('child_added', (res) => {
const playerName = res.key;
// Create Peer channel
const peer = new SimplePeer();
// Listen for signaling data from specific player
playerRef.on('child_added', (res) => peer.signal(JSON.parse(res.val().data)));
// Upload signaling data from host
const signalDataRef = database.ref('/rooms/'+code+'/host/'+playerName);
peer.on('signal', (signalData) => {
const newSignalDataRef = signalDataRef.push();
newSignalDataRef.set({
data: JSON.stringify(signalData)
});
});
});
From this point forward, the host and the player can communicate using peer.on(‘data’, cb)
and peer.send(data)
. The player's machine terminates its firebase connection once connected with the host, and the host does the same when the game starts.
And that’s it! At this point I had bidirectional communication between the host and all of the players, just like I would with a traditional server, so all that was left was to make the game and pass data between the players.
Getting User Input
User input is sent as a JSON object whenever the key changes state. Example: { up: true }
The host keeps track of each player's input states and uses them to move the players each frame.
Sharing the Game State
In order to keep the game development simple, I wanted to use the 2D game framework Phaser. The game runs on the host machine, and handles things like physics and collisions. Each frame, the position and size of every sprite is serialized and sent to each player. To make things easy, I simply used the sprite data to redraw the whole game in the player's browser each frame. Because my game only uses a handful of sprite the process works fine, but a more complex game would likely require a more efficient process for sharing the game state.
Gameplay
The game I made to test all of this is a simple side scroller. Platforms randomly appear, and the last player remaining on a platform wins. Apologies if you encounter any impossible gaps, I didn't spend much time polishing it.
Notes
Because the game server is running on one of the player’s machine, it is potentially vulnerable to manipulation by that player. This system should work fine for playing games with friends though, as long as your friends aren’t cheaters!
Conclusion
I was able to set up a peer-to-peer multiplayer game that uses only ~2kb of server bandwidth per player. I should be able to support 500,000 players per month on the Firebase free tier! As a bonus, I was able to keep my code concise enough to fit most of it in this post. I think WebRTC is a neat technology, and I am excited to see what other projects will be built with it.
Posted on July 10, 2017
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.