Building a multiplayer websocket server
main()
Let's start by looking at our entry point.
We start by setting up OpenSSL and our server socket, before we start our main application loop.
setup_openssl()
When setting up OpenSSL we provide it with a certificate and a private key. For local development these can be generated with the OpenSSL.
openssl req -x509 -newkey rsa:4096 \
-keyout key.pem -out cert.pem \
-sha256 -days 3650 -nodes -batch
setup_server()
Next we setup our non-block server TCP socket.
handle_new_connections()
We're now in the main loop and the first thing we do is look for new connections.
When we receive a new connection - we wrap it in a Connection struct.
After we wrap the new socket in our connection struct. We give it the first state PRE_TLS.
The connection goes through 3 states before it becomes a "Client" and then join the server.
- PRE_TLS
We've just accepted the connection and are now waiting for OpenSSL to complete it's handshake. - PRE_WEBSOCKET
The TLS connection is estrablished, and now we're waiting for the first WebSocket message from the client. - PRE_INDENT
After we've processed the WebSocket message, we wait for the first message that identifies the client. This is just an application message (our own message) that contains the appearance and the chosen name.
handle_connections()
Now we look at all the connections that are trying to connect to the server.
If any of them are taking too long to connect to the server - we kill the connection.
handle_tls_connection()
Here we wait for OpenSSL to complete it's handshake.
Since we're using non-blocking sockets - SSL_ERROR_WANT_READ and SSL_ERROR_WANT_WRITE from OpenSSL is very common.
These simply inform us that OpenSSL isn't ready for read or write respectively - since it's in the middle of it's handshake. So if we receive one of them we just have to try again later.
If we receive an other error we kill the connection.
If everything goes smoothly - we change the state to PRE_WEBSOCKET
handle_websocket_connection()
It seems the W3C committee was paranoid (rightfully so) that WebSockets could be used to communicate with a server, that wasn't original designed for WebSockets.
So the WebSocket "handshake" might seem a bit strage.
The client/browser will send this HTTP request to our server. Asking for a connection upgrade to websocket.
GET / HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: http://example.com
The value of Sec-WebSocket-Key is combined with "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" and hashed with SHA1 and base64 encoded.
The UUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" is a expected UUID written in the WebSocket specification.
The reply from the server will look like this.
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: YjM3YTRmMmNjMDYyNGYxNjkwZjY0NjA2Y2YzODU5NDViMmJlYzRlYQ==
Reading WebSocket messages
Now we've upgraded the connection, so we can send and receive messages.
WebSocket messages are pretty simple - they wrap the data with a small amount of meta data.
This graph from the specification does a pretty good job at showing what a message consist of.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
- The first bit tell wether this is the final message or if more messages have to be combined with this message. For our server we only accept final messages.
- The next 3 bits aren't used. They can be used if you're using a custom extension.
- The next 4 bits are used to describe what the message is. It can be one of 6 things.
- 0x0 for non-final messages.
- 0x1 for a text message.
- 0x2 for a binary message.
- 0x8 for a connection close message.
- 0x9 for a ping message.
- 0xA for a pong message.
- The next bit tells if the message is masked. Clients must always mask their message. But we won't have to.
- The next byte contains the length of the message. Except:
- If the length is 126 - we instead read the next 2 bytes to get the length.
- If the length is 127 - we instead read the next 8 bytes to get the length.
- After we've read the length the next 4 bytes is the mask from the client.
- And then finally we get the message payload - which is masked.
Writing WebSocket messages
Buffer
Eventhough it's not relevant for our WebSocket server, I think I would just show the buffer helper functions.
handle_ident_connection()
Now we're ready for the final state of the connection.
handle_clients()
Now our connection has been upgrade to a client, and it's time to process the chat messages and position updates.
If we haven't received a ping message for 3 seconds - we kill the client.
Final words
That's more or less it. Below you'll be able to find the complete server code - along with the client code.
If you find any vulnerabilities - I'm more than happy to know about them!
<html>
<head>
<style>
#container { width:800px; height:600px; position:relative; }
#overlay { width:100%; height:100%; background:#00000099; position:absolute; top:0; left:0; display:flex; }
#character { width:400px; }
#input { width:400px; padding:50px; display:flex; align-items:center; }
#input div { display:flex; flex-direction:column; width:100%; }
#input input { font-size: 20px; padding:10px; background:#ffffffCC; border:1px solid #333; margin-bottom:20px; }
#input button { font-size: 20px; padding:10px; background:#99ff99CC; border:1px solid #333; margin-bottom:20px; }
#character { display:flex; align-items:center; justify-content:center; }
#characterpanel { display:flex; height:128px; padding:40px; background:#ffffff99; border-radius:10px; }
.layers { width:128px; height:128px; position:relative; }
.left { display:flex; flex-direction:column; }
.right { display:flex; flex-direction:column; }
.layer { background:url('atlas.png'); width:128px; height:128px; position:absolute; }
.arrow { width:32px; height:32px; background:#fff; margin:1px; border:0; }
#error { color:#ff6666; font:24px monospace; }
</style>
</head>
<body>
<div id="container">
<canvas id="canvas" tabindex='1'></canvas>
<div id="overlay">
<div id="character">
<div id="characterpanel">
<div class="left">
<button class="arrow" onclick="changeLeft(0)"><</button>
<button class="arrow" onclick="changeLeft(1)"><</button>
<button class="arrow" onclick="changeLeft(2)"><</button>
<button class="arrow" onclick="changeLeft(3)"><</button>
</div>
<div class="layers">
<div id="layer1" class="layer" style="z-index:4; background-position:0 0"></div>
<div id="layer2" class="layer" style="z-index:3; background-position:0 -128px"></div>
<div id="layer3" class="layer" style="z-index:2; background-position:0 -256px"></div>
<div id="layer4" class="layer" style="z-index:1; background-position:0 -384px"></div>
</div>
<div class="right">
<button class="arrow" onclick="changeRight(0)">></button>
<button class="arrow" onclick="changeRight(1)">></button>
<button class="arrow" onclick="changeRight(2)">></button>
<button class="arrow" onclick="changeRight(3)">></button>
</div>
</div>
</div>
<div id="input">
<div>
<input id="name" type="text" placeholder="Name" maxlength="11">
<button onclick="connect()">Connect</button>
<div id="error"></div>
</div>
</div>
</div>
<div>
<script>
let drawInterval;
let pingInterval;
let nameparts = ['Bop','Glee','Squig','Zip','Twist','Fuzz','Jump','Splat','Dizzy','Buzz'];
document.querySelector('#name').value =
nameparts[Math.floor(Math.random()*nameparts.length)] +
nameparts[Math.floor(Math.random()*nameparts.length)];
let layers = [
document.querySelector("#layer1"),
document.querySelector("#layer2"),
document.querySelector("#layer3"),
document.querySelector("#layer4")
];
let layerValues = [0, 0, 0, 0];
let appearance = 0;
function changeLeft(v) {
layerValues[v] = ((layerValues[v] - 1)+4) % 4;
layers[v].style.backgroundPosition = `${layerValues[v]*-128}px ${v*-128}px`;
appearance = layerValues[0] + layerValues[1]*4 + layerValues[2]*16 + layerValues[3]*64;
console.log(appearance);
}
function changeRight(v) {
layerValues[v] = (layerValues[v] + 1) % 4;
layers[v].style.backgroundPosition = `${layerValues[v]*-128}px ${v*-128}px`;
appearance = layerValues[0] + layerValues[1]*4 + layerValues[2]*16 + layerValues[3]*64;
console.log(appearance);
}
let fullWidth = 800;
let fullHeight = 600;
let areaPadding = 50;
let areaWidth = fullWidth - areaPadding*2;
let areaHeight = fullHeight - areaPadding*2 - 20;
let tileWidth = areaWidth / 8.5;
let tileHeight = areaHeight / 8;
let canvas = document.querySelector('#canvas');
canvas.width = fullWidth;
canvas.height = fullHeight;
let ctx = canvas.getContext('2d');
const atlas = new Image();
atlas.src = "atlas.png";
const room = new Image();
room.src = "room.png";
function loadImage(img) {
return new Promise((resolve, reject) => {
img.onload = () => resolve(img);
img.onerror = () => reject(new Error(`Failed to load image at ${src}`));
});
}
Promise.all([loadImage(atlas), loadImage(room)])
.then(([img1, img2]) => {
ctx.drawImage(room, 0, 0, 800, 600);
})
.catch(error => {
console.error('Error loading images:', error);
});
function connect() {
let name = document.querySelector('#name').value;
if (name.length <= 0 && name.length > 11) return;
let clientId;
let clients = [];
let typedMessage = "";
const socket = new WebSocket("wss://localhost:4430");
socket.binaryType = 'arraybuffer';
function Buffer(buffer) {
this.buffer = buffer;
this.dataView = new DataView(this.buffer);
this.offset = 0;
}
Buffer.prototype.read8 = function () {
var v = this.dataView.getUint8(this.offset);
this.offset += 1;
return v;
};
Buffer.prototype.read16 = function () {
var v = this.dataView.getUint16(this.offset);
this.offset += 2;
return v;
};
Buffer.prototype.read32 = function () {
var v = this.dataView.getUint32(this.offset);
this.offset += 4;
return v;
};
Buffer.prototype.read64 = function () {
var v = this.dataView.getUint64(this.offset);
this.offset += 8;
return v;
};
Buffer.prototype.read = function (l) {
const value = new TextEncoder().encode(v);
for (let i = 0; i < value.length; i++) {
this.dataView.getUint8(this.offset, value[i]);
this.offset += 1;
}
};
Buffer.prototype.write8 = function (v) {
this.dataView.setUint8(this.offset, v);
this.offset += 1;
};
Buffer.prototype.write16 = function (v) {
this.dataView.setUint16(this.offset, v);
this.offset += 2;
};
Buffer.prototype.write32 = function (v) {
this.dataView.setUint32(this.offset, v);
this.offset += 4;
};
Buffer.prototype.write64 = function (v) {
this.dataView.setUint64(this.offset, v);
this.offset += 8;
};
Buffer.prototype.write = function (v) {
const value = new TextEncoder().encode(v);
for (let i = 0; i < value.length; i++) {
this.dataView.setUint8(this.offset, value[i]);
this.offset += 1;
}
};
socket.addEventListener("open", (event) => {
const arrayBuffer = new ArrayBuffer(1024, { maxByteLength: 1024 });
const buffer = new Buffer(arrayBuffer);
buffer.write8(1); // Message type
buffer.write8(appearance);
buffer.write8(name.length); // Name length
buffer.write(name); // Name
socket.send(arrayBuffer.transferToFixedLength(buffer.offset));
pingInterval = setInterval(sendPing, 500);
});
socket.addEventListener("message", (event) => {
if (!(event.data instanceof ArrayBuffer)) return;
const buffer = new Buffer(event.data);
var messageType = buffer.read8();
if (messageType == 2) { // Init world
clientId = buffer.read8();
document.querySelector('#overlay').style.display = 'none';
let numberOfClients = buffer.read8();
clients = [];
for (let i = 0; i < numberOfClients; i++) {
let client = {
id: buffer.read8(),
appearance: buffer.read8(),
position: buffer.read8(),
name: "",
};
let nameLength = buffer.read8();
for (let n = 0; n < nameLength; n++) {
client.name += String.fromCharCode(buffer.read8());
}
console.log("Client ", client.name, " in the room");
clients.push(client);
}
} else if (messageType == 3) { // Connect
let id = buffer.read8();
let appearance = buffer.read8();
let position = buffer.read8();
let nameLength = buffer.read8();
let client = clients.find(x => x.id == id);
if (client) {
client.appearance = appearance;
client.position = position;
client.name = "";
client.message = "";
for (let n = 0; n < nameLength; n++) {
client.name += String.fromCharCode(buffer.read8());
}
} else {
let client = {
id: id,
appearance: appearance,
position: position,
message: "",
name: ""
};
for (let n = 0; n < nameLength; n++) {
client.name += String.fromCharCode(buffer.read8());
}
clients.push(client);
console.log("New client ", client.name);
}
} else if (messageType == 4) { // Disconnect
let id = buffer.read8();
let index = clients.findIndex(x => x.id == id);
if (index > -1) {
console.log("Client disconnect ", clients[index].name);
clients.splice(index, 1);
}
} else if (messageType == 11) { // Message
let clientId = buffer.read8();
let messageLength = buffer.read8();
let message = "";
for (let n = 0; n < messageLength; n++) {
message += String.fromCharCode(buffer.read8());
}
let client = clients.find(x => x.id == clientId);
if (client) {
client.message = message;
client.messageTime = Date.now();
}
} else if (messageType == 21) { // Move
let clientId = buffer.read8();
let position = buffer.read8();
let client = clients.find(x => x.id == clientId);
if (client) {
client.position = position;
}
} else if (messageType == 200) { // Server full
document.querySelector('#error').innerHTML = "Server is full";
}
});
function sendChat() {
const arrayBuffer = new ArrayBuffer(1024, { maxByteLength: 1024 });
const buffer = new Buffer(arrayBuffer);
buffer.write8(10);
buffer.write8(typedMessage.length);
buffer.write(typedMessage);
socket.send(arrayBuffer.transferToFixedLength(buffer.offset));
}
function moveTo(position) {
const arrayBuffer = new ArrayBuffer(1024, { maxByteLength: 1024 });
const buffer = new Buffer(arrayBuffer);
buffer.write8(20);
buffer.write8(position);
socket.send(arrayBuffer.transferToFixedLength(buffer.offset));
}
function getTileFromPosition(x, y) {
x = x - areaPadding;
y = y - areaPadding*2;
let ty = Math.floor(y / tileHeight);
if (ty % 2 == 1) x -= tileWidth/2;
if (x < 0 || y < 0 || x > tileWidth*8 || y > tileHeight*8) {
return -1;
}
return Math.floor(ty*8) + Math.floor(x/tileWidth);
}
function getPositionFromTile(t) {
let x = t % 8;
let y = Math.floor(t / 8);
x = x * tileWidth;
if (y % 2 == 1) x += tileWidth/2;
y = y * tileHeight;
return { x: x+areaPadding, y: y+areaPadding+areaPadding };
}
function sendPing() {
const arrayBuffer = new ArrayBuffer(1024, { maxByteLength: 1024 });
const buffer = new Buffer(arrayBuffer);
buffer.write8(9);
socket.send(arrayBuffer.transferToFixedLength(buffer.offset));
}
function draw() {
ctx.fillStyle = '#000';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(room, 0, 0, 800, 600);
let clientsToDraw = clients.slice();
clientsToDraw.sort((a,b) => a.position-b.position);
ctx.textAlign = 'center';
ctx.font = "bold 14px sans serif";
ctx.textBaseline = 'hanging';
ctx.strokeStyle = '#000';
ctx.lineWidth = '5';
ctx.fillStyle = '#00f';
for (let i = 0; i < clientsToDraw.length; i++) {
let client = clientsToDraw[i];
let tile = getPositionFromTile(client.position);
ctx.drawImage(atlas, 128*((client.appearance >> 6) % 4), 128*3, 128, 128, tile.x+(tileWidth-128)/2, tile.y+tileHeight-128, 128, 128);
ctx.drawImage(atlas, 128*((client.appearance >> 4) % 4), 128*2, 128, 128, tile.x+(tileWidth-128)/2, tile.y+tileHeight-128, 128, 128);
ctx.drawImage(atlas, 128*((client.appearance >> 2) % 4), 128*1, 128, 128, tile.x+(tileWidth-128)/2, tile.y+tileHeight-128, 128, 128);
ctx.drawImage(atlas, 128*((client.appearance >> 0) % 4), 128*0, 128, 128, tile.x+(tileWidth-128)/2, tile.y+tileHeight-128, 128, 128);
ctx.fillStyle = '#fff';
ctx.strokeText(client.name, tile.x+tileWidth/2, tile.y+tileHeight);
ctx.fillText(client.name, tile.x+tileWidth/2, tile.y+tileHeight);
}
let currentClient = clients.find(x => x.id == clientId);
if (currentClient) {
let tile = getPositionFromTile(currentClient.position);
if (typedMessage.length == 0 && currentClient.message && currentClient.message.length > 0 && (Date.now() - currentClient.messageTime) < 5000) {
let measure = ctx.measureText(currentClient.message);
ctx.fillStyle = "#333333BB";
ctx.fillRect(tile.x+tileWidth/2 - measure.width/2 - 10, tile.y-80-measure.emHeightAscent-10, measure.width+10+10, measure.emHeightAscent+10+10+10);
ctx.fillStyle = '#fff';
ctx.fillText(currentClient.message, tile.x+tileWidth/2, tile.y-80);
} else if (typedMessage.length > 0) {
let measure = ctx.measureText(typedMessage);
ctx.fillStyle = "#333333BB";
ctx.fillRect(tile.x+tileWidth/2 - measure.width/2 - 10, tile.y-80-measure.emHeightAscent-10, measure.width+10+10, measure.emHeightAscent+10+10+10);
ctx.fillStyle = '#999';
ctx.fillText(typedMessage, tile.x+tileWidth/2, tile.y-80);
}
}
for (let i = 0; i < clientsToDraw.length; i++) {
let client = clientsToDraw[i];
let message = client.message;
if (!client.messageTime || (Date.now() - client.messageTime) > 5000) continue;
if (!client.message || client.id == clientId) continue;
let tile = getPositionFromTile(client.position);
let measure = ctx.measureText(client.message);
ctx.fillStyle = "#333333BB";
ctx.fillRect(tile.x+tileWidth/2 - measure.width/2 - 10, tile.y-80-measure.emHeightAscent-10, measure.width+10+10, measure.emHeightAscent+10+10+10);
ctx.textAlign = 'center';
ctx.font = "bold 14px sans serif";
ctx.textBaseline = 'hanging';
ctx.fillStyle = '#fff';
ctx.fillText(message, tile.x+tileWidth/2, tile.y-80);
}
}
function click(event) {
let tile = getTileFromPosition(event.offsetX, event.offsetY);
if (tile == -1) return;
let clientOnTile = clients.find(x => x.position == tile);
if (clientOnTile) return;
moveTo(tile);
}
canvas.addEventListener('click', click);
canvas.addEventListener('keydown', (event) => {
if (event.key.length === 1 && event.key.charCodeAt(0) >= 32 && event.key.charCodeAt(0) <= 126 && !event.altKey && !event.ctrlKey) {
if (typedMessage.length < 60) { typedMessage += event.key; event.preventDefault(); }
}
else if (event.key == 'Escape') { typedMessage = ''; }
else if (event.key == 'Enter') { sendChat(); typedMessage = ''; }
else if (event.key == 'Backspace') { typedMessage = typedMessage.slice(0, -1); }
});
clearInterval(drawInterval);
drawInterval = setInterval(draw, 100);
draw();
}
</script>
</body>
</html>
Thanks!
Thank you for sticking around till the end. I hope it has been informative and perhaps you've learned a thing or two. :)
If you have any questions, thoughts, or comments, feel free to reach me at hello@eibx.com