State is what the server knows about your game world at any given time. In Colyseus terms, state is per-room, so it’s more apt to say that “state is what the server knows about the game room at any given time”. There are some challenges in that statement which the Mazmorra source-code has shed light upon for me, and we’ll cover that in this article.

State is a…State of Mind

Colyseus requires that, for any object intended to act as a state container, it derive from a base class called Schema. Schema is a construct that Colyseus can serialize, and that is super mega important for all kinds of reasons.

State isn’t just what the server knows about the game room, but it’s also what the server uses to tell the client about the game room. While the state is stored in a state object, certain properties within that state are converted from their code-centric selves into the plain ol’ JSON format in order to be transmitted to the client. In the previous post I ended by saying that when createPlayer is called on the state object, the player was created but not returned to the client. This is because the createPlayer method adds the database info into the state, and those properties are automatically transmitted to the client as part of the state refresh cycle.

Automatic for the People

Stepping back a smidge, we understand that a game server has a game loop. As a concept, the game loop fires multiple times a second and each pass performs logic on a whole host of things that the server needs to calculate in order to simulate “the game” and to deal with input from various clients. Part of this cycle includes notifying clients of Things They Should Know(tm), and specifically about any changes in state that affect them.

When a client connects to a room for the first time (causing onCreate or onJoin handlers on the server to fire), they will receive the full state dossier from the server. This gives the client everything they need to know about the room they inhabit, if the state object exposes that info as a serializable property. That’s important: only the schema serializable properties are transmitted to the client; any other properties or methods defined within the state object are not.

Then, on a regularly scheduled cycle, Colyseus will check the state of the state in each occupied room. If any of those serializable properties have changed since the last frame, then the changes are bundled up and are sent to any client in the current room where the changes took place. Because each room has its own state, something that happens in Sector 95 is only sent to clients in Sector 95, and won’t be sent to clients in Sector 113.

This is how movement happens: A client sends a message to the server that they want their avatar to move one square up. Chances are they only send one letter: the “W” key (or something mobile-specific if they are touching themselves via phone or tablet). The server’s onMessage will listen for the “move” tag and when received, will pass the data to a method on the player object that’s related to client who sent the message. After doing the calculation to determine if the move is possible, the player state is updated with the new position — x:0, y:y-1. When the next game loop tick fires off and the position change has been identified, the position serializable properties in player state will be sent to all clients in the room. The clients, then, will take that data and update their placement of the client who wanted to move. This is authoritative since the server — not the client — determined if the movement was possible, and the server “made the move” and notified the client that they should keep up with the changes and re-draw their screen. The client knows nothing except what the server tells it.

SectorState Properties

export class SectorState extends Schema { ... }

To define our SectorState we extend the Colyseus base-class Schema. This makes it possible for us to mark properties for serialization.

@type("string")
id: string;
@type("number")
designation: number;
@type("string")
name: string;
@type("number")
x: number;
@type("number")
y: number;
@type("boolean")
isMajor: boolean;
@type("boolean")
isMinor: boolean;
@type({map: Unit}) Units = new MapSchema<Unit>();

These are properties which are to be serialized. As such, these are the properties that will be transmitted to clients in a given room. Some, like name, will be displayed to the client. Others, such as id, designation, x, y, isMajor and isMinor, might be used by the client, or might only be provided so that further actions by the player have the correct data to send back to the server to make those actions happen. Remember, the server is the authority, so while the player might want to enter Sector 95, they can only signal their intent to do so. If Sector 95 is not accessible from their current sector, they should get a message stating that they can’t just jump across the galaxy willy-nilly.

Of interest is the last property, Units. Because of inheritance — what allows us to extend from the base class Schema — we can extend from several base classes, which in turn might also extend from base classes. Here, the Units collection is a base class that we’ll be extending from to create two different classes: Player and NPC. Units provides serializable properties that both Player and NPC require, and also allow us to refer to either Player or NPC because they both derive from a common base. Essentially this collection will store references to all Player and NPC objects created in this room.

players: {[id: string]: Player } = {};
npcs: {[id: string]: NPC } = {};

unitsToRemove: Unit[] = [];

fileOps: DataFileOps = new DataFileOps();

Next we have local properties. These are not serializable and therefor will not be sent to the clients. They are, however, accessible to any instantiation of the SectorState object, such as a room.

In addition to our Units collection, we have two collections, players and npcs. While Units stores all entities deriving from Unit, these two collections break out the Unit types based on their derived implementations. This will allow us to access either player or NPC objects without having to parse their instanced type.

Finally, we have a reference to the data file operator class.

Constructor

We can include a special method called constructor that is fired when a class is instantiated.

constructor(sectorId: string) {
        super();

        //Load the sector(s) and filter the one we want.
        let universe: dbSector[] = this.fileOps.Load("universe.json");
        let dbs: dbSector = universe.filter(s=>s.designation.toString() === sectorId)[0];

        this.id = dbs.id;
        this.designation = dbs.designation;
        this.name = dbs.name;
        this.x = dbs.x;
        this.y = dbs.y;
        this.isMajor = dbs.isMajor;
        
        console.log(`Sector ID ${sectorId} has been created`);
}

When we create a new SectorState instance, we pass in a sectorId so we have a record of which sector we’re dealing with. This “sector” is the data representation of the sector, and includes all of the serializable properties mentioned above as well as other information such as planets, stations, and jumpgates (which I haven’t gotten to yet, but which will probably become serializable properties as well).

super() is a requirement of deriving from another class and including a constructor. This basically means that when we construct this derived class, construct the base class as well. If the base class has arguments for its constructor, we’ll need to pass them in via super.

The rest of this method loads the sector data from the data store specified by the sectorId, and then assigns the data points to the properties of the class. We have now officially defined our SectorState state object for our room, and we’re ready to roll. Sort of.

Room-Accessible Properties and Methods

I want to call back to our SectorRoom implementation.

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

So, remember how when we extended Room we passed a type of SectorState? What that allowed us to do was to access that state within the room implementation by using this.state. So in the room implementation, if I needed to access the players collection held within the SectorState implementation, I could do so using this.state.players. This also allows access to methods defined within SectorState from within our room so that we can update our state object in response to specific needs. We’ve already seen one of these in the discussion of the Room: createPlayer.

createPlayer(client, db: dbPlayer, options: any): Player{
    let player = new Player(db.id, db, this);

    //Add player to Units and players.
    this.players[player.id] = player;
    this.addUnit(player);

    //return player;
    return player;
}

This is a bit anemic right now. createPlayer accepts the instance of client, a database object which, in my case, is loaded from a JSON text file, and any options that the method might need.

The first thing this method does is create a Player object. This object is actually a state object of it’s own, so we are defining a state object within a state object, and once we create the Player state object, it’s assigned to the players collection. It’s also being passing into another method, addUnit.

addUnit(unit:Unit){
    this.Units.set(unit.id,unit);

    if (unit instanceof NPC){
        this.npcs[unit.id] = unit;
    }
}

Although addUnit is being called from a method internal to the SectorState implementation, it is also accessible from our room implementation. As mentioned earlier, Unit is a base class from which we derive both Player and NPC, so when we say that the inbound argument unit is of type Unit, that means “any class that extends Unit“. Our createPlayer method is passing it’s Player object into addUnit, which firstly adds the inbound item to the serializable Units collection. As this is serializable, this property collection will be sent to all clients in the room. On the client side, we’ll be able to iterate through this collection and alert the player to which other players (or NPCs) are in the room as well. As Unit types are added to this collection, the clients can update a list of other entities that are present in the room.

We do make a special case for NPC Unit objects. If the implementation of Unit is a more specific NPC type, then we want to add that unit to the npcs collection. Note that we did this same thing within the createPlayer method, but added the Player object to the players collection. We’re doing the same thing for each derived type, but we don’t have a dedicated createNPC method just yet, so NPCs will need to be added to their collection elsewhere (I haven’t gotten far enough to care about NPCs yet).

removeUnit(unit:Unit){
    this.unitsToRemove.push(unit);

    if (unit instanceof NPC) {
        delete this.npcs[unit.id];
      }
}

Of course for every add we need a matching remove. When a player leaves a room, we need to remove them from their collection, but since players have a more important cleanup routine than an NPC does, we handle it through an extended chain of methods. In this method, though, we add our Unit-derived object to another collection: unitsToRemove. Then, if this is an NPC derived type, we delete it from the npcs collection.

It seems that according to the Mazmorra source, there should be a few methods that every class should have. One of those is dispose.

dispose() {
    // free up memory!
    this.disposeUnits();
        
    delete this['Units'];
    delete this['players'];
    delete this['npcs'];
}

We didn’t talk about what happens to a room when the last client disconnects. By default, when there are no more clients in a room, the room will auto dispose. This will raise the room event onDispose. The Colyseus documentation suggests that by calling this asynchronously, we can write the state of the room to a persistent data store. In the Mazmorra source, the room’s own dispose method calls the state’s dispose method, deleting all items in the three collections we have set up (Units, players, and npcs). It also calls a state method disposeUnits.

disposeUnits() {
   if (this.unitsToRemove.length > 0) {
     for (let i = 0; i < this.unitsToRemove.length; i++) {
       const unit = this.unitsToRemove[i];
       this.Units.delete(unit.id);
       unit.dispose();
     }
     this.unitsToRemove = [];
   }
}

At this point, the code loops through any records in the unitsToRemove collection and then starts deleting them from the Units collection one by one. Each unit instance has it’s own dispose method called, allowing each unit to “say its goodbyes” before being obliterated. In the end, the unitsToRemove collection is reset.

Other Schema: Unit and Player

Because they factor in so heavily into the above discussion, we need to talk about Unit and Player state objects.

export class Unit extends Schema {
    //Serializable properties
    @type("string")
    id: string;
    @type("string")
    name: string;
    @type("number")
    wallet: number;
    @type("string")
    factionId: string;
    @type(Ship)
    ship: Ship
    @type(Cargo)
    cargo: Cargo;

    //Local properties


    constructor()
    {
        super();
    }

    /*
    Fires on game loop
    */
    update() {}

    /*
    Sell an item in Cargo for the rate dictated by the current Market
    */
    cargoSell() {}

    /*
    Buy an item at current Market price and put it into Cargo
    */
    cargoBuy() {}

    /*
    What happens when we die?
    */
    onDie() {}

    /*
    Remove from the current State
    */
    dispose() {}
}

In the Unit object, the serializable properties that are defined at those which would be common to any class that extends this base class. This includes id, name, wallet, faction, Ship, and Cargo (the last two are under review). All entities that float around the sector need these properties and need to have them passed into the room on update. Right now some of the key methods are stubbed out.

Back in the createPlayer method, we instantiated a new Player object where we passed in some values to the constructor. The Player class looks like this so far:

export class Player extends Unit {

    db: dbPlayer;
    
    sectorId: string;
    sectorNumber: number;

    isOnline: boolean = false;

    constructor(db: dbPlayer, state?){
        super();

        this.id = db.id;
        this.name = db.name;
        this.wallet = db.wallet;
        this.factionId = "faction1";
        
        //Ship and Cargo...

        db = db;
    }

    /*
    Fires on game loop
    */
    update() { super.update(); }

    /*
    Sell an item in Cargo for the rate dictated by the current Market
    */
    cargoSell() { super.cargoSell(); }

    /*
    Buy an item at current Market price and put it into Cargo
    */
    cargoBuy() { super.cargoBuy(); }

    /*
    What happens when we die?
    */
    onDie() { super.onDie(); }

    /*
    Remove from the current State
    */
    dispose() { super.dispose(); }
}

The constructor of the Player object takes in the dbPlayer object which is the database data transport object, and an optional reference to the state which is instantiating it. In the constructor we’re assigning values from the data transport object to the properties of the Player object. Right now, all of those properties are defined within Unit and are serializable, meaning they’ll get sent to the client. Later, I’ve stubbed out the same methods seen on Unit, currently with super calls so that I could add additional, player specific logic here, or I could do nothing and let the control flow head into the Unit implementation.

Looking Ahead – Simple Client

That’s about all I have right now for my test server. When running the server, it’ll listen on port 3030, and when a client connects, it will pull data from my data files to find the right sector. Then, I’ve hard-coded a player ID value so the client can assume a client identity for instantiation purposes.

In the next and final post (for now), we’ll look at the current test client, create in React.

Scopique

Owner and author.

Leave a Reply

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