node package manager

trajan

Tarjan implements the following

  • Authentication & Authorization for the client.
  • Provides an interface for accounting (WIP stripe) etc this is done by mantaining a fully capable Restful API.
  • Ability to swap in and out multiple transports depending on your game model.
  • Handles match-making. Default uses ping based model however you can extend this to account for pretty much anything.
  • It handles firing up rooms (the implemenation is left to you as of now).
  • It provides a simple injection point for the game developer's business logic much inspired by https://webtask.io
  • It is an electron package so when you want to debug all you do is double click ūüėČ or run it from cli.
  • Handles lag compensation using redux's reducers.

It provides Colyseus Game Server as a service. In future more things should be moved to colyseus which should provide a simple pluggable api in which case trajan will focus on

  • Provisioning.
  • Management.
  • Accounting.
  • Authentication.
  • Match Making.
  • Replays/Asset management.
  • Live updates client & server.
  • Richer Developer Expreience.
  • Inbuilt support for twitch streaming.

Usage

Setup(WIP)

Install Trajan by doing

$npm install -g trajan

At this point you can call

$trajan init [ProjectName]

to create your project, each project has the following structure

/src
    index.ts
tsconfig.json
package.json
trajan.json
yarn.lock

Programming Your Game

Your game & match-making logic is written inside the default server.js looks like the following (I'm using pong for my example). You'll need to provide match making logic and game loop logic.

import dbconnector from 'some-database-module';
import Trajan from 'trajan';
 
export default async function init(trajan) {
    const {config} = trajan;
    const {
        MAX_ALLOWED_SKILL_GAP,
        FAIR_SKILL_GAP,  
        DB_URI,
    } = config;
 
    trajan.useDatabase(dbConnector.connect(config.DB_URI)));
    // Please note that trajan is stateless, aka this is  
    // a middleware that will be passed to express in order 
    // to actually authenticate, the game server authentication 
    // is handled by Trajan in the database provider 
    trajan.useAuth(Trajan.Authenticators.OIDC, {
        idp: 'https://some-game.auth0.com',
        aud: 'https://pong.com/',
        algorithm: 'RS256',
    });
 
    // Depending on the game you can have differences here 
    trajan.useTransport(Trajan.Transports.UDP);
 
    trajan.setBroadcastRate(20); // Always per second. 
    trajan.setTickRate(60); // Always per second. 
 
    trajan.setSyncStrategy(Trajan.SateSync.Interpolation);
    trajan.setMaxAllowedPing('500ms');
    trajan.setEndClientDelay('100ms');
    
    trajan.setMatchMaker({
        async getInfo(client){
            const data = await db.get(client.userId);
            return data;
        },
        async matches(room, playerInfo){
            const avgSkill = room.getAverageSkill();
            const playerSkill = playerInfo.skill;
 
            return (FAIR_SKILL_GAP - Math.abs(avgSkill - playerSkill))/FAIR_SKILL_GAP;
        },
        async mergingFeasibility (roomA, roomB) {
            const avgSkillA = roomA.getAverageSkill();
            const avgSkillB = roomB.getAverageSkill();
 
            return (MAX_ALLOWED_SKILL_GAP - Math.abs(avgSkillA - avgSkillB))/MAX_ALLOWED_SKILL_GAP;
        }
        maxPlayersInRoom: 2,
        minPlayersInRoom: 2,
        waitFor: '10s',        
    });
    
    // The view filter will describe "what" will be sent  
    // out to each client, this is helpful when you want 
    // to implement complex scenarios like Fog of War. 
    trajan.setViewFilter(function (player, state) {
        return state;
    });
 
    // Just serialize whatever we got from the ^ state. 
    trajan.setSerializer((state) => Buffer.from(JSON.stringify(state))); 
 
    // This must be your synchronous game loop 
    // please note that this is a very naive implementation 
    // of pong 
    trajan.setGameLoop(function (state, action) {
        const {type, client, payload} = action;
 
        switch(type) {
            case 'SETUP':
                state.players = {},
                state.ball = null;
                state.scores = {};
            break;
            case 'PLAYER_JOIN': // Player joins the room 
                state.players[client.id] = {
                    y: Object.keys(state.players.length) > 0? 200: 0,
                    x: 0, xVel: 0,
                    width: 40,
                    height: 5,
                    score: 0,
                };
                // This special fireld will only be updated and sent 
                // when the response for a specific tick from both 
                // clients have been recieved. 
                state.scores[client.id] = Trajan.Criticial.Number();
            break;
            case 'INIT':
                state.ball = {
                    x: 200,
                    y: 200,
                    // Bad Bad idea. 
                    angle: Math.random() * Math.PI * 2,
                    vel: 4.0,
                    size: 5
                }
            break;
            case 'TICK':
                // Over here you can extrapolate. 
                doBallPhysics(state);
                handleScoringLogic(state);
            break;
            case 'PLAYER_MOVE_LEFT':
                state.players[client.id].xVel -= 1.0;
                state.players[client.id].xVel %= 5;
            break;
            case 'PLAYER_MOVE_RIGHT': 
                state.players[client.id].xVel += 1.0;
                state.players[client.id].xVel %= 5;
            break;
        }
 
        doCollisionDetection(state);
        return state;
    });
}
 

The game style can be an implementatoon of Trajan.SyncStrategy. Out of the box trajan provides

  • DeterministicLockstep The game only exists on the server, therefore all inputs must be recieved before the server will respond with any data. This is similar to flagging all data as Critical in the state schema. This is usually the least bandwidth requiring solution

  • Interpolation The game simulation runs on server and the game sends packets to the client which can then respond accordingly, when an input packet is recieved we roll back in time and calculate the Critical data. This requires usually, abysmal amount of bandwidth.

  • StateSync The simulation runs on both the client and the server, and the server sends critical data to the client. Please make sure Trajan does not handle implementation details of doing this for you. Please read the StateSync tutorial for more, this usually gives the best performance and latency however we are syncing both the state and the action thereby this may or may not suit your requirements and usually requires decent amount of work.

By default, Trajan uses udp in realtime mode and tcp sockets in non-realtime mode. However, depending on your requirements you can completely change the transpot strategy by implementing a Transportor this should be moved to Colyseus in future. The transporter interface is defined as follows.

interface Transporter{
    onData: (client: Client, buffer: Buffer) => void;
    onConnect: (client: Client) => void;
    send(buffer: Buffer): Promise<void>;
    listen(port: int): Promise<boolean>;
}

Trajan provides Trajan.Transports.UDP, Trajan.Transports.TCP, Trajan.Transports.WebSocket you can easily extend these and add support for whatever protocol you wish to implement, you even extend one of the given ones to change how they work.

To reduce the amount of data sent to the client, trajan allows you to configure selection strategies. This is implemented by the following function

trajan.setViewFilter(function (client: Client, state: GameState) {
    // Over here you can decide what exactly the user can see 
    // for a simple shooter this can be defined as the following 
    
    const visibleState = {};
    const self = state.players[client.id];
    visibleState.players = _.filter(state.players, (player) => {
        if (self.id === player.id) {
            return true;
        }
        if (aabb(self, player)) {
            if (distance(player, self) < MAX_DISTANCE) {
                if (canRayCast(player, self)) {
                    return true;
                }
            }
        }
        return false;
    });
});

Obviously the above function can be futher optimized to account for if you can see me i can see you scenarios. To do this client.visibleState will be made available for a client for which this calculation is completed ahead of time.

Futhermore, if you don't like JSON Trajan allows you to provide a different serializer and deserializer for your data, this allows you to be purely flexible and do any sorts of optimizations you may need to do. For convieninece we provide Serializer.ProtoBuf please refer to Google's Guide on how to use ProtoBuf.

Now comes the bigger problem, what if things changed beyond the field of view that need to be sent, for lossy transportation this becomes a big problem as depending on the game the state might need to be transferred or not in future (say score) or death or a rocket firing. The idea here is that whatever state was flagged as important will be sent to client with Critical fields recieving the highest priority. Do not flag every field as Critical as this will stress the queue, furthermore each non-sent part is prioritized for the next step.

How Lag Compensation works in Realtime Games.

Every multiplayer game with authoritive server can be defined as the following loop

  • Get Client Input: This handles with transporting the information server regarding "WHAT" is happening.
  • Update State: This is where your business logic should be.
  • Emit finalized state as snapshots: This is what all the clients see.

Trajan handles the first and last part so you can focus only on your business logic, this is achieved by following the redux model for states. However, there is one change the state will be sync'ed to the clients view of the game at that point.

In an ideal world a pong game could be world we could write a game of pong as a naive reducer like the following assume state is passed the version of client's view from n milliseconds ago.

function (state, action) {
    const {type, client, payload} = action;
 
    switch(type) {
        case 'SETUP':
            state.players = {},
            state.ball = null;
            state.scores = {};
        break;
        case 'PLAYER_JOIN': // Player joins the room 
            state.players[client.id] = {
                y: Object.keys(state.players.length) > 0? 200: 0,
                x: 0, xVel: 0,
                width: 40,
                height: 5,
                score: 0,
            };
            // This special fireld will only be updated and sent 
            // when the response for a specific tick from both 
            // clients have been recieved. 
            state.scores[client.id] = 0;
        break;
        case 'INIT':
            state.ball = {
                x: 200,
                y: 200,
                // Bad Bad idea. 
                angle: Math.random() * Math.PI * 2,
                vel: 4.0,
                size: 5
            }
        break;
        case 'TICK':
            // Over here you can extrapolate. 
            doBallPhysics(state);
            handleScoringLogic(state);
            doCollisionDetection(state);
        break;
        case 'PLAYER_MOVE_LEFT':
            state.players[client.id].xVel -= 1.0;
            state.players[client.id].xVel %= 5;
        break;
        case 'PLAYER_MOVE_RIGHT': 
            state.players[client.id].xVel += 1.0;
            state.players[client.id].xVel %= 5;
        break;
    }
 
    return state;
}

Now comes the problem, what if a very late packet arrives from Player 2. Do we count score or not? This is usually by the way gamers hate people with high ping in pong (no pun intended). Because sometimes it seems that the player missed it but half a second later the game screen updates with the ball coming back at you.

This happened because when the older packet arrived the server fetched the older state and replaced all the newer data with the older one. The changes will be even more diabolical if Player 1 (with much lower latency) had moved they'll experience what you'd call a jitter in movement because of the actions of the other player.

So how do we solve this? The ideal way to handle this in a purely distributed scenario the solution would be to state - synchronize via a model where every action would result in only 1 outcome. But that doesn't seem like a reasonable model for realtime games. You just can't wait to "sync".

Enter Redux + MobX inspired state management for game states.

This is how your code will look like in Trajan

function (state, action) {
    const {type, client, payload} = action;
 
    switch(type) {
        case 'SETUP':
            state.players = {},
            state.ball = null;
            state.scores = {};
        break;
        case 'PLAYER_JOIN': // Player joins the room 
            state.players[client.id] = {
                y: Object.keys(state.players.length) > 0? 200: 0,
                x: 0, xVel: 0,
                width: 40,
                height: 5,
                score: 0,
            };
            // This special fireld will only be updated and sent 
            // when the response for a specific tick from both 
            // clients have been recieved. 
            state.scores[client.id] = Trajan.Criticial.Number();
        break;
        case 'INIT':
            state.ball = {
                x: 200,
                y: 200,
                // Bad Bad idea. 
                angle: Math.random() * Math.PI * 2,
                vel: 4.0,
                size: 5
            }
        break;
        case 'TICK':
            // Over here you can extrapolate. 
            doBallPhysics(state);
            handleScoringLogic(state);
            doCollisionDetection(state);
        break;
        case 'PLAYER_MOVE_LEFT':
            state.players[client.id].xVel -= 1.0;
            state.players[client.id].xVel %= 5;
        break;
        case 'PLAYER_MOVE_RIGHT': 
            state.players[client.id].xVel += 1.0;
            state.players[client.id].xVel %= 5;
        break;
    }
 
    return state;
}

What sorcery is this?

There is no sorcery here, Trajan uses timeframe therefore when a state is updated it extrapolates the further states which were calculated during the time the original state was updated.

Basically if your commands change the physics engine then its much easier to fix things as you can always run the simulation as an extrapolation from that point in time.

In the above example the engine knew what changed and could extrapolate data based on those values on then next frame when merging.

What about things like rocket fire?*

Things which do not necessarily require being emitted as the rocket will always move in a single direction. The idea here is that extrapolation is sufficient for such-things as physics is simulated on both sides the server provides correction. This strategy is discussed in depth in http://gafferongames.com/networked-physics/state-synchronization/

The idea here is that the physics engine will run on both the client and the server and thus we can sync states freely.

Client side ?

TODO, although on client side the best part is you can have something similar to this, this is acheived by abstracting the sync strategies

function (state, action) {
    const {type, client, payload} = action;
 
    switch(type) {
        case 'TICK':
            // Over here you can extrapolate. 
            doBallPhysics(state);
            handleScoringLogic(state);
            doCollisionDetection(state);
        break;
        case 'PLAYER_MOVE_LEFT':
            state.players[client.id].xVel -= 1.0;
            state.players[client.id].xVel %= 5;
        break;
        case 'PLAYER_MOVE_RIGHT': 
            state.players[client.id].xVel += 1.0;
            state.players[client.id].xVel %= 5;
        break;
    }
 
    return state;
}

Yep, no extra logic needed pretty much the same logic as the server without the extra parts.

Deploying your game.

This is not out yet but Trajan has ability to run in 3 modes

  • Management Provider
  • Game Loop Runner
  • Both

Depending on which machine it has to run as & workload, it can dynamically switch stance. However, this is work in progress, you can test this locally if you have Docker installed. You can use the --autoscale=mock flag to mock the autoscaling using Multiple Docker VMs, please note that this will impose a requirement on your deployment strategy.