Now, I might be misunderstanding exactly how these things work, but I was once again testing Colyseus, the Javascript multiplayer game server framework, with React and came across what I assume is a gotcha: maintaining a connection to a server.

React’s claim to fame is conditional refresh of components. If new data arrives that a component consumes, only that component should refresh. Sometimes, though, an entire cascade of components can refresh, especially when relying on useState to store info, and props to pass info from parent to child component.

When dealing with a web sockets connection, though, I got it in my head that refreshing a component that holds a socket connection is bad. Refreshing would mean that either A) the connection is reset, or B) the connection is reset and reconnected under a new session. I don’t know if this is how Colyseus works, as it seems mainly targeted at tick-driven platforms like Unity and other game engine-slash-development platforms, and not a stateless medium like React. Whether this is true or not, I didn’t want to put everything the React app knows about a Colyseus connection inside of the components who need to communicate with the server, so I needed a way to establish the connection to the server once, and then expose a way for any component to send and receive messages via that central controller.

This might be overkill, held together with spit and duct-tape, or the proper way to handle it, but here’s the rundown of what’s currently working.

The Colyseus Hook

Using the Colyseus Javascript client library starts off with a single assignment:

const client = new Colyseus.Client('ENDPOINT:PORT');

A connection doesn’t open to the server until the client attempts to join a “room”. Colyseus uses the concept of “rooms” and “room state” to group clients together and keep them apprised of public information stored in that room’s state. If a client isn’t connected to a room, they are not connected to the server, so my hook needs to establish connection to a room, keep that room reference around, and also use it for sending and receiving messages and state updates.

In order to do this, I created a context wrapper that sits at a very high level in the application, hopefully up where component refreshes aren’t going to be a problem. Here’s the wrapper:

<ColyseusContext.Provider value={colyseus}>
      {props.children}
</ColyseusContext.Provider>

The ColyseusContext is a standard React context; nothing fancy. The value value, however, is an object that holds references to the things other components might need to know about:

const colyseus = {
        client: client,
        room: currentRoom,
        EnterRoom: props.EnterRoom || EnterRoom,
        RegisterMessageCallback: props.RegisterMessageCallback || RegisterMessageCallback
    }

Client is the original client object we saw at the start of this post. I’m not sure the components need access to that, but I’m keeping it for now. Room is the room that was joined. The value assigned, currentRoom, is a state object which is storing the room reference once we have entered. EnterRoom is a function reference that a component can call to move into a new room. This is an idea in flux, which I’ll get to later. Finally, I work up at 3AM this morning with an idea on how to get components decoupled from some of this hard-coded reference passing, and RegisterMessageCallback is the result.

This object is passed via context, which any component can access via the useColyseus hook.

export const useColyseus = () => {
    return useContext(ColyseusContext)
}

When used in a component, it looks like this:

import { useColyseus } from './Colyseus';
...
const colyseus = useColyseus();

//Register local functions as server message callbacks
//Do this before we try and enter a room
useEffect(()=>{
    colyseus.RegisterMessageCallback("Welcome", handleWelcome);
},[])

//A handler wired to a button that puts us in a room
const handleEnterRoom = (e) =>{


    colyseus.EnterRoom({roomName:'basic'});
}

//The callback function
const handleWelcome = (msg) =>{
    console.log(msg);
}

The Callback Queue

My 3AM epiphany was to allow the hook to listen for any message from the game server, parse the message type (a string or number tag that identifies who or what the message is for), and then pass the message payload to the appropriate callback. I had done this with the EnterRoom method originally, but thought “why not make it more general purpose?” Components can then define local handlers that expect a certain message type and deal with the message payload. By storing these callbacks in the hook component with the type string and function signature, I can loop through them when a message has been received and send the message payload to any objects with matching tags.

const RegisterMessageCallback = (tag, callback) => {
    if (callbackQueue.length > 0){
        const haveIt = callbackQueue.filter(itm=>{return(itm.tag === tag && itm.callback === callback)});
        //If false, add it to an existing queue.
        if (haveIt.length === 0){
            setCallbackQueue(callbackQueue.push({tag:tag, callback:callback}));
        }
    }else{
        //This is the first in the queue
        const newQueue = [{tag:tag, callback:callback}]
        setCallbackQueue(newQueue);
    }
}

If our state bin is empty, we add a new callback object with the tag and callback properties. If we already have the tag and callback combo in state, don’t do anything; we’ll need another method to remove a callback, but that’s for a later date.

When a message arrives from the server, we need to parse the type and look through the callback queue. Since we aren’t connected until we enter a room, the message listener is set up in the EnterRoom method.

const EnterRoom = ({roomName}) =>{
    client.joinOrCreate(roomName, { user:"Scopique"})
    .then(room => {
        setCurrentRoom(room);
        room.onMessage("*", (type, message)=>{
            HandleServerMessage(type, message);
        })
    })
    .catch(err=>{
        console.log(err);
    })
}

Here we can see one of the issues I ran into last night: joinOrCreate, which allows the client to join a room specified by roomName, returns a Promise. Promises are extremely powerful, but also a massive pain in the ass. Normal functions execute their code and return their result, but Promises return immediately while they execute their code, meaning we can never be certain that anything returned from a function using a Promise has accurate data. My clients couldn’t access the room value because they wouldn’t know when the joinOrConnect Promise had returned a viable room object.

The solution was to store the room reference in state (a client can only occupy one room at a time, which might be an issue later if we have to split focus between “game room” and “chat room” simultaneously), and then to define a general-purpose message handler. Using room.onMessage(“*”, (type, message)=>{}) accepts all messages from the server sent either directly to the client or via broadcast to all room clients.

We parse the intended recipient via type, and pass the payload stored in message. HandleServerMessage is where we break out the callback queue:

const HandleServerMessage = (tag, message)=> {
    //Loop through all callbacks in the callbackQueue and match by tag
    //Send message to each callback
    if (callbackQueue.length > 0){
        const sendTo = callbackQueue.filter(itm=>{return (itm.tag.toUpperCase() === tag.toUpperCase())});
        if (sendTo.length > 0){
            sendTo.forEach(itm=>{
                if ((itm.callback && typeof itm.callback === 'function')){
                    itm.callback(message);
                }
            })
        }
    }
}

Nothing fancy here. If we have callbacks registered, get a sub-set of those whose tag matches the type value sent by the server. if we have results, loop through them, sending the message payload to the callbacks. In this test case, the server will send over a message with the type of “welcome”, and a message payload of “Welcome to the server {user}!”. Since we’ve registered a callback handleWelcome with the tag of “Welcome” in our main application component, when the server sends the message the component will call console.log and print the server message to the browser console.

Looking Ahead

One concern I have for the future is that this callback queue will need to hold callbacks for all callbacks in all components who need to get a live message from the server. That could be a lot, which will increase the app’s memory footprint and might slow down the passing of messages as the code has to loop through matching tags to send a message to each callback. This could be minimized by registering callbacks at a parent level and passing results down via props in typical React fashion. That way, if multiple components called from a shared parent need part of the same message, the parent would accept it and then dole out the info to those child components who need it.

Another question that I haven’t even approached is how the concept of room state will work with this system. A room’s entire state package is sent to the client when the client enters a room, and then as a delta on a fixed timer. Clients can access state on-demand, outside of the initiation of a message. That would mean that components would need to have access to the room object set when the client enters the room. It’s currently in the hook’s object payload, but it’s got that nasty Promise issue hanging above its head.

Finally, I will need to clean up and possibly separate the callback queue mechanism. I might leave it inside the Colyseus wrapper-slash-hook file, but I don’t want this file to get massive; any abstraction or compartmentalization I can do to keep things orderly will help me in the long run.

Here’s the complete code for the hook:

import React, { useState, useContext } from 'react';
import * as Colyseus from 'colyseus.js';

const ColyseusContext = React.createContext(null);

export const ColyseusProvider = (props) => {
    const [ currentRoom, setCurrentRoom ] = useState(null);
    const [ callbackQueue, setCallbackQueue ] = useState([])

    const client = new Colyseus.Client('ws://localhost:3553');

    const EnterRoom = ({roomName}) =>{
        client.joinOrCreate(roomName, { user:"Scopique"})
        .then(room => {
            setCurrentRoom(room);
            room.onMessage("*", (type, message)=>{
                HandleServerMessage(type, message);
            })
        })
        .catch(err=>{
            console.log(err);
        })
    }

    const RegisterMessageCallback = (tag, callback) => {
        if (callbackQueue.length > 0){
            const haveIt = callbackQueue.filter(itm=>{return(itm.tag === tag && itm.callback === callback)});
            //If false, add it.
            if (haveIt.length === 0){
                setCallbackQueue(callbackQueue.push({tag:tag, callback:callback}));
            }
        }else{
            //This is the first
            const newQueue = [{tag:tag, callback:callback}]
            setCallbackQueue(newQueue);

        }
    }

    const HandleServerMessage = (tag, message)=> {
        //Loop through all callbacks in the callbackQueue and match by tag
        //Send message to each callback
        if (callbackQueue.length > 0){
            const sendTo = callbackQueue.filter(itm=>{return (itm.tag.toUpperCase() === tag.toUpperCase())});
            if (sendTo.length > 0){
                sendTo.forEach(itm=>{
                    if ((itm.callback && typeof itm.callback === 'function')){
                        itm.callback(message);
                    }
                })
            }
        }
    }

    const colyseus = {
        client: client,
        room: currentRoom,
        EnterRoom: props.EnterRoom || EnterRoom,
        RegisterMessageCallback: props.RegisterMessageCallback || RegisterMessageCallback
    }

    return(
        <ColyseusContext.Provider value={colyseus}>
            {props.children}
        </ColyseusContext.Provider>
    )
}

export const useColyseus = () => {
    return useContext(ColyseusContext)
}

And here’s the code for the page which uses the hook:

import React, { useEffect, useState } from 'react';
import { useColyseus } from './Colyseus';

function App() {

  const [ currentRoom, setCurrentRoom ] = useState(null);

  const colyseus = useColyseus();

  useEffect(()=>{
    colyseus.RegisterMessageCallback("Welcome", handleWelcome);
  },[])
  
  const handleEnterRoom = (e) =>{
    colyseus.EnterRoom({roomName:'basic'});
  }

  const handleWelcome = (msg) =>{
    console.log(msg);
  }

  return (
    <div className="App">
      <button onClick={handleEnterRoom}>Enter Room</button>
    </div>
  );
}

export default App;

Finally, the Colyseus context wrapper is included in the index.js file, which is as high as we can go in the component cascade before we get to the HTML rendering file.

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';

import { ColyseusProvider } from './Colyseus';

ReactDOM.render(
  <React.StrictMode>
    <ColyseusProvider>
      <App />
    </ColyseusProvider>
  </React.StrictMode>,
  document.getElementById('root')
);

Sound off!

This site uses Akismet to reduce spam. Learn how your comment data is processed.