When I say “setting up a server”, I’m overreaching here; I’m not an expert on any of this, and these articles are mainly my way of leaving notes to my future self as well as for anyone like me who might be smart enough to understand these technologies, but isn’t always quick enough to get it right away. I’m going to be talking about a simple test that I’ve put together as a way of coming to grips with how Colyseus works, and as a way to figure out ways to structure my project.

A Server Serves

“Server” is an applicable name because at it’s core, a server serves up content. When it first spins up, it’s a little like that neighborhood kid who has just hung out her first “Lemonade for Sale” sign in her front yard. She sits there, chin in hands, awaiting her first client.

Stock images shall provide the perfect match for any metaphor.

When a client connects, then the server kicks into gear.

Colyseus’ Room Paradigm

Colyseus works with the concept of rooms. Consider a room like an area in the original Legend of Zelda game.

Each area that Link inhabited was basically “a room”. The room not only contained Link: The Character, but also enemies, power-ups, obstacles, walls, and pathways that lead to other rooms. As the player guides Link between rooms, the program would check it’s information about what the current room looked like and what it should provide, and it would do whatever it needed to in order to present that data to the player.

Much to Link’s ire, I’m sure, the code didn’t care to remember everything about the state of each room that Link had visited, and returning to a previously visited room meant that all of the enemies that had been vanquished there had returned. State in a game — especially an online game — is extremely important when players expect progress to be linear and decisions and actions to matter in the long run.

When a client connects to a Colyseus server, then, they do so by entering a room. Each room is its own entity, and each room has it’s own state knowledge. If we were making The Legend of Zelda using Colyseus, then we might create one room per screen, set up the number and type of enemies, what power-ups we want the player to find there, and set up a way to render a map. In fact, Endel Dreyer, creator of Colyseus, has a built a roguelike game using Colyseus called Mazmorra which you can play right now in your browser!

Hell, if you want to stop reading these posts and see what Colyseus can do for you, you can check out the source code on GitHub. I have been camping this project for the past week, dissecting it as best as I am able in a bid to figure out how best to approach my own project.

A Basic Server

So what does a basic server look like? There’s a lot of boilerplate info involved that requires some knowledge of React and Express which I’m not going to talk about, but I will hopefully explain enough to give the more casual reader an idea of what’s going on. For everyone else, check out the GitHub link above for a more in-depth example. Some of my code here was taken from that repo, because why reinvent the wheel?

require('dotenv').config()
import { Server, Room, matchMaker } from 'colyseus';
import http from 'http';
import express from 'express';

import { SectorRoom } from './rooms/SectorRoom';

const port = process.env.PORT || 3030;
const app = express();

app.use(express.json());

const server = http.createServer(app);
const gameServer = new Server({
  server: server,
  express: app,
  pingInterval: 8000,
  pingMaxRetries: 3,
});


gameServer.define('sector', SectorRoom).filterBy(['sectorId']);
/*
gameServer.define('onboarding', CreationRoom);

gameServer.define('station', StationRoom).filterBy(['stationId']);
gameServer.define('market', MarketRoom).filterBy(['marketId']);
gameServer.define('shipyard', ShipyardRoom).filterBy(['shipyardId']);
gameServer.define('bar', BarRoom).filterBy(['barId']);
gameServer.define('combat', CombatRoom).filterBy(['combatId']);
*/

server.listen(port);

console.log(`Listening on http://localhost:${ port }`)

That’s it! Surprised? As I had mentioned a server in and of itself is pretty simple. There’s a lot of prep code up front that sets up some info we’ll need, like which port the server uses, initializing Express for use with Node and Colyseus, and the creation of the gameServer object. At the end of the file, the server starts to listen for connections, passively sitting there, chin-in-hands, for the clients to come rolling in.

What’s unique, then, is the stuff in the middle 2/3 of the code: the gameServer.define() statements. Those are what define the rooms that we’ll be offering.

Room With A View

What does a room look like? A room is a class which extends another class. Extending a class is a programming concept that basically says “take something that exists, and build something custom on top of it”. Here, our base class is Room, and we are extending it to create SectorRoom.

export class SectorRoom extends Room<SectorState> { ... }

The <SectorState> is an object type which we’ll get to later.

Next, we define some local variables that the room will use for various activities.

sectorId: string;

players = new WeakMap<Client, Player>();
clientMap = new WeakMap<Player, Client>();

fileOps: DataFileOps = new DataFileOps();

sectorId is a string that I set up to hold inbound information on which sector the player wants to join. The players and clientMap items are WeakMaps. WeakMaps are collections of objects that have an identifying key. The inclusion of these to is based on the Mazmorra code, with the players and clientMap collections are mirror images of one another: the first stores a collection of player objects that are “in the room” and is indexed by their client reference, while the second stores a collection of client references indexed by their player object. The reason we have two is so that no matter which source object we have to work with, we can always get the other without having to resort to convoluted iteration or filtering techniques. fileOps is my temporary file-access library that allows me to load the JSON data I had created to represent players, sectors, planets, stations, and so on.

Room Events

A Colyseus room has two major functions: respond to events recognized by the room object, and to respond to input from clients. From a server perspective, there are a handful of intrinsic events that it must handle. Events are raised when a client attempts to perform an action against the server. The first intrinsic event handler we define is onCreate, which activates when the very first client attempts to enter the room.

async onCreate(options){
    this.sectorId = options.sectorId;

    this.players = new WeakMap();
    this.clientMap = new WeakMap();

    this.setState(new SectorState(this.sectorId));

    this.registerMessages();
}

async means that although this event is going to fire when a client attempts to enter, it’ll tell the client “I’ll be right with you!” as it continues to finish up what it’s currently doing. Maybe one client is in the process of joining the room when another signals its intent to join. The process for onboarding the first client needs to complete independent of handling the second client, because if we waited for each client’s onboarding process to finish before another is even started, can you imagine the queue if we had 1000 clients waiting to connect?

The options that this method receive are passed in from the client, and we’ll look at those when we switch to the client side of this test. Know, however, that the options is an object which contains properties and data that we need to ensure that the client correctly connects and that the server knows everything it needs to in order to provide the correct info to the client as a result of all of this back-and-forth communication.

One of the option data properties is sectorId. Because this game needs to use a single room definition for what might eventually be 1000 different sectors, the server needs to know which sector the client is attempting to enter. This is done by using the sectorId to get data from the database (eventually). Here, we are assigning the value in that option property to a local variable of the same name.

Next, we instantiate our two WeakMaps. With collections, we usually have to instantiate or initialize them. Collections can get cranky if we try and use them before they even know anything about themselves, so these two lines help keep them happy for when we need to use them later on.

this.setState is how we register the state of the room. As mentioned, the state of a room records all of the important information about the room not just for the client who caused onCreate to fire, but for all other clients who connect afterwards. If the first client to connect blows up a space station (not that they will be able to, but it’s a good example), we need to ensure that anyone who enters the room after sees the debris. We do this by updating the state of the room, although at this point we’re merely setting it up for the first time.

Finally, we’re calling a local method registerMessages which we’ll look at in a bit.

The next event we need to handle is onJoin.

async onJoin(client: Client, options: any){
    let players: dbPlayer[] = this.fileOps.Load("player.json");
    let dbp: dbPlayer = players.filter(p=>p.id === options.playerId)[0];

    const player = this.state.createPlayer(client, dbp, {sectorId: this.sectorId});
    this.players.set(client, player);
    this.clientMap.set(player, client);
}

The first client who attempts to connect to a room will cause the onCreate method to fire, and will then cause the onJoin event to fire. Additional clients will only cause the onJoin event to fire if the room has already been instantiated. Whereas the onCreate event was responsible for setting up the overall room’s initial state, the onJoin event is specific to individual clients, and this is where we set up the data about the character that the player is playing.

Here, we accept two arguments: client and options. The client argument has a type Client. Removing the obfuscation that Colyseus provides, we could get down to the base web socket code. At this level, a client represents network information that is passed from the user-side to the server-side. We care about this because each connected client has their own unique signature, and we use this as a way to ensure we deal with each client as it’s own separate entity. We’ll see client being passed into many functions throughout this project.

The next two lines are my stopgap data retrieval process. First I’m loading the entire players database into an array, and then I’m filtering down to one single player by matching the data’s id property to the playerId that was sent into the onJoin method. This is a super-simple method and ultimately is not very secure — anyone skillful enough could forge a playerId and impersonate someone else’s character — but for now it works.

This is not “the player” quite yet because although we have the data from the data store, we don’t have a character in the sense that the server needs. For that, we call a method createPlayer which, as you can see, is part of the state. We’re providing the client, the data about the player we got from the data store, and the sectorId value that was set when onCreate was called. This method exists within the state class implementation SectorState which we’ll get to in a bit. The result of this method is returned to the room, and we set up entries in our two WeakMap collections. Now the room knows about the character, and has linked the client object to the player object so the room can get access to either when it needs it.

Right now the only other event handler I have defined is onLeave. When the client wants to disconnect, or is forcibly disconnected from the server due to an interruption, this is fired from the client side.

async onLeave(client: Client, consented: Boolean){
    console.log(`Client disconnected. ${consented ? "Was" : "Was not"} consented.`);
}

The only item of note here is consented, which is TRUE if the user on the client side decided to disconnect, or FALSE if the socket connecting the client and server was closed without the proper goodbye. This will be important much later on if we want to provide a reconnection window for users who might have gotten dropped due to a crappy connection.

Room Methods

I have but one room method currently: registerMessages.

registerMessages() {
    this.onMessage("*", (client, key, value)=>{
        const player = this.players.get(client);

        if (key === "ping"){
            client.send("pong",{"message":"pong!"});
        }
    })
}

Up until this point, the event handlers we’ve seen have been intrinsic parts of the Colyseus design and respond to very specific messages from client events. As with any game, though, a server needs to know how to deal with messages that are intended to fire off game logic such as movement, casting a spell, selling inventory, or equipping an item. This is where message handlers come into play.

In this implementation, based on the Mazmorra code, a single method registers message handlers. The this.onMessage function takes two arguments: what to listen for, and a function that fires when what it listens for has been heard. Here we’re using the wildcard “*” which basically means that this onMessage will pass every custom message that the client sends to the server while the client is in this room. The anonymous function has three arguments: client, which we have spoken of before (client data is passed around a lot), key which is an identifying tag that the client will send to tell the server what the message is about, and value which is the actual message that the client is sending.

The first line in the handler uses our WeakMap players. Passing in the client, it will pull out the value of the collection which matches the key, giving us the player object that matches the client who is sending this message. Pretty cool!

Then comes the individual message responders. Right now I have one test message responder which will get called if the client sends a message with the key tag “ping”. In response, we use the client object to send a message back with the key value of “pong” and the message payload of an object with the single property message and the value “pong!”. Why is the client sending and not room or some other server-level object? This is because we only want to send this message to the client who sent this message. There are other ways to send broadcast messages to every client in this room, but we don’t want to do that here; we only want to talk to a single, specific client. That client in turn will need to have it’s own onMessage method which expects a pong tag and a message payload. As we send this info back to the client, the client will be able to do whatever it needs to with that info.

Of course, in a real situation we’d take in a tag like move with data indicating the direction the player wants to move. The handler would pass that info to another method (defined on the player object, for example) which calculates the efficacy of the requested action and if legal, will update the player state…but doesn’t return the new coordinates via client.send.

Why? We’ll get into that in the next post about server state.

Scopique

Owner and author.

Leave a Reply

Your email address will not be published. Required fields are marked *