Custom JavaScript Multiplayer with Web Sockets

Contributed by Nicholas Bottone in May 2022

Before deciding whether to implement this guide, you should familiarize yourself with networked-aframe to see if that library might suit your needs.  You can proceed with this guide to implement your own collaboration implementation that will give you deeper control than networked-aframe will give you.

Sockets allow opening a real-time communication channel between clients and a server, allowing each end of the socket to send messages down the socket and set listeners to act when a message is received.  There are two popular options for dealing with sockets in JavaScript.

1)      Socket.io – Uses HTTP long polling and upgrades this connection to use WebSocket in the event both devices support it.  Generally considered more reliable since it can use HTTP as a fallback.

2)      WebSocket API – The WebSocket API is increasingly supported by modern browsers and most use cases can get away with only using the WebSocket API, which may be more performant than using Socket.io as a wrapper.


You will need to start with creating your backend server.  The server will be responsible for listening to incoming connections, receiving messages about state changes, processing these messages, and then updating the state of all the connected clients.  On a proof-of-concept scale, you can also build a client-authoritative application by setting up a server that simply forwards all received messages to all connected clients.  This is easier to set up, however it might not be adequate if your app cares about security or accuracy of inputs.

 

You can create a new Socket.io server in JavaScript by passing it a Node HTTP or HTTPS service.  Note that if you choose to use Node’s HTTPS service, you will need to provide an SSL key and certificate.  After initializing your Socket.io server instance, you can setup listeners to await new connections and particular events/messages on each connection.

You may want to setup a basic “hello world” HTTP route on your server to easily indicate your server is running/working.  This is optional, and you can also simply setup your server to only listen for socket connections.  You can use HTTP server’s built in request handling, or you can use Express.

 

Setting up an HTTP server with Socket.io:

const httpServer = http.createServer((_req, res) => {

  res.statusCode = 200;

  res.setHeader('Content-Type', 'text/html');

  res.end('<h1>Hello, World!</h1>');

});

 

const ioServer = new Server(httpServer);

 

To listen for connection and disconnection events with Socket.io server:

ioServer.on("connection", (socket) => {

  console.log("a user connected");

  socket.on("disconnect", () => {

    console.log("user disconnected");

  });

});

 

See an example implementation of this backend architecture built with Socket.io on the VRWiz repository.

 

On your frontend, you are ready to connect to the socket server.  If using Socket.io on the client side, you can connect simply by calling the io function:

const socket = io('ws://localhost:8080');

 

socket.on("connect", () => {

  console.log("connected to server");

});

 

If using WebSockets without Socket.io on the client side:

const ws = new WebSocket('ws://localhost:8080');

 

ws.onopen = (event) => {

  console.log('WebSocket connected');

};

 

To send events/messages through the socket:

// Socket.io

socket.emit("message name", "message contents");

// WebSocket

ws.send('Hello, world!');


To expect these events, you should register a listener for every specific event type on both your frontend and backend.  For example, you may have an event for when a user joins the session, when a user presses a button, etc.

// Socket.io

socket.on("message name", (data) => {

  console.log(data); // "message contents"

});

// WebSocket

ws.onmessage = (event) => {

  console.log(event.data); // "Hello, world!"

};

 

See an example implementation of this frontend architecture built with Socket.io on the VRWiz repository.

 

If you are hosting your backend application on a Linux machine that you have terminal access to (i.e., AWS or self-hosting): Use PM2 to host your backend application in production.  PM2 is highly configurable and allows you to set your server to watch for file changes and hot reload when there is a new update, manage saving logs to files, and automatically start the server on system boot to ensure consistent uptime.  You can install PM2 with NPM globally.  You can then create an ecosystem configuration file to save your desired application settings across multiple launches.  For your production app, I suggest setting up your environment variables here with “env_production”, your app entry point with “script”, and file watching with “watch”.  To setup a persistent application to launch when your system boots, you should follow the startup script generator guide.

Some additional design considerations!

I highly recommend using TypeScript and building interfaces and custom types for your user objects and other objects that you may be passing across the sockets to other clients.  TypeScript will enforce you to be consistent with the way you defined your interfaces, along with adding type checking elsewhere throughout your application, which helps you detect and prevent bugs before they happen!  Without TypeScript, you will only find out about this bug at runtime, and you will have a tough time debugging since there are no dev tools inside the VR headset.  With TypeScript, your IDE will show you the errors before you commit your changes.

Create an interface of the user object containing position and rotation coordinates for the head as well as for each controller.  Also include attributes that you may want to tie to the user, such as a custom name/username, color, etc.  One of the advantages of implementing your own multiplayer framework is getting complete control over the data that you can send to other clients – take advantage of the deep level of control you have to send whatever data you want over the socket!