Building a multiplayer game with colyseus.io

s1hofmann

Simon Hofmann

Posted on November 7, 2020

Building a multiplayer game with colyseus.io

Computer games are awesome! Not only are they fun to play, but they’re also quite fun to build. Virtually every programmer, at one point or another, has at least thought about building a game.

That said, building games is not easy, and it takes a lot of imagination to create something truly impressive. If you want to build a multiplayer game, you must not only create a great game but also set up all the networking, which is a daunting task in itself.

Colyseus is designed to reduce the burden of networking so you can fully concentrate on your game mechanics. To demonstrate what it has to offer, we’ll implement a multiplayer Tetris clone — we’ll call it Tetrolyseus.

Getting started - Colyseus Backend Setup

Colyseus provides an npm-init initialiser which automates the creation of new projects.

npm init colyseus-app ./my-colyseus-app
Enter fullscreen mode Exit fullscreen mode

This interactive initialiser will take care of our basic setup. While it’s also possible to use Colyseus with plain old JavaScript or Haxe, we are going to stick with TypeScript.

? Which language you'd like to use? …
❯ TypeScript (recommended)
  JavaScript
  Haxe
Enter fullscreen mode Exit fullscreen mode

Once completed we will have the following files generated for us in my-colyseus-app:

.
├── MyRoom.ts
├── README.md
├── index.ts
├── loadtest
├── node_modules
├── package-lock.json
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode

We will dive right into Colyseus by taking a closer look at

  • index.ts
  • MyRoom.ts

index.ts

The newly created index.ts file is our main entry point which sets up our server:

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


app.use(cors());
app.use(express.json())

const server = http.createServer(app);
const gameServer = new Server({
  server,
});
Enter fullscreen mode Exit fullscreen mode

While not being necessarily required, the default colyseus-app templates also uses express, so we’re able to easily register additional route handlers on our backend. In case we do not want to provide additional handlers our setup boils down to:

const port = Number(process.env.PORT || 2567);

const gameServer = new Server();
Enter fullscreen mode Exit fullscreen mode

The second part of our index.ts file is where we actually expose our game logic:

// register your room handlers
gameServer.define('my_room', MyRoom);

// skipped for brevity

gameServer.listen(port);
console.log(`Listening on ws://localhost:${ port }`)
Enter fullscreen mode Exit fullscreen mode

Colyseus uses the notion of ”rooms” to implement game logic. Rooms are defined on our server with a unique name which our clients use to connect to it. A room handles client connections and also holds the game’s state. It is the central piece of our game, so we will see what they look like next.

MyRoom.ts

import { Room, Client } from "colyseus";

export class MyRoom extends Room {
  onCreate (options: any) {
    this.onMessage("type", (client, message) => {
      // handle "type" message
    });
  }

  onJoin (client: Client, options: any) {
  }

  onLeave (client: Client, consented: boolean) {
  }

  onDispose() {
  }
}
Enter fullscreen mode Exit fullscreen mode

As we can see, a few lifecycle events are attached to a Colyseus room.

  • onCreate is the first method to be called when a room is instantiated. We will be initialising our game state and wiring up our message listeners in onCreate
  • onJoin is called as soon a new client connects to our game room
  • onLeave is the exact opposite to onJoin, so whenever a client leaves, disconnect and reconnection logic will be handled here
  • onDispose is the last method to be called right before a game room will be disposed. Things like storing game results to a database and similar tasks might be carried out in onDispose An additional event, although not included in the default room implementation, is onAuth. It allows us to implement custom authentication methods for joining clients as shown in the authentication API docs.

Now that we’ve gained an overview of a basic Colyseus backend setup, let’s start modelling our game state.

You can find the code we wrote so far in the accompanying repository on GitHub. The corresponding tag is 01-basic-setup:

git checkout tags/01-basic-setup -b 01-basic-setup
Enter fullscreen mode Exit fullscreen mode

Managing Game State

In one way or another, every game is holding state. Player position, current score, you name it. State makes the backbone of a game.
When talking about online multiplayer games, state becomes an even more complex topic. Not only do we have to model it properly, but now we also have to think about how we’re going to synchronise our state between all players.
And that’s where Colyseus really starts to shine. Its main goal is to take away the burden of networking and state synchronisation so we’re able to focus on what matters - our game logic!

Stateful Game Rooms

Previously we learned that a Colyseus room is able to store our game state. Whenever a new room is created, we initialise our state:

import { Room, Client } from "colyseus";
import { MyGameState } from "./MyGameState";

export class MyRoom extends Room<MyGameState> {
  onCreate (options: any) {
    this.setState(new MyGameState());
    ...
  }

  ...
}
Enter fullscreen mode Exit fullscreen mode

Every time a client connects to our room it will receive the full room state in an initial synchronisation, automatically.
Since room state is mutable, it has to be synced continuously. However, following the full state sync, Colyseus will only send incremental updates which are applied to the initial state. The interval for state syncs is configurable for each room via its patchRate and defaults to 50 milliseconds (20 fps). Shorter intervals allow for fast-paced games!

So without further ado, let’s model our state!

Position

The two-dimensional Tetrolyseus board consists of several rows and columns. The Position state object is used to store the position of our active Tetrolyso block by its top-left row and column:

import {Schema, type} from "@colyseus/schema";

export class Position extends Schema {
    @type("number")
    row: number;

    @type("number")
    col: number;

    constructor(row: number, col: number) {
        super();
        this.row = row;
        this.col = col;
    }
}
Enter fullscreen mode Exit fullscreen mode

Our state class has to fulfil certain properties to be eligible for synchronisation:

  • It has to extend the Schema base class
  • Data selected for synchronisation requires a type annotation
  • A state instance has to be provided to the game room via setState

Position is a simple state class which synchronises two number properties: row and col. It nicely demonstrates how Colyseus Schema classes allow us to assemble our state from primitive types, automatically enabling synchronisation.

Board

Next up is our game board state. Similar to Position it stores two number properties, the rows and cols of our two-dimensional game board. Additionally, its values property holds an array of numbers, representing our board.
So far, we only worked with single data, so how are we going to model our state class holding a data collection? With Colyseus, collections should be stored in an ArraySchema, Colyseus’ synchronizable Arraydatatype for one-dimensional data.

import {ArraySchema, Schema, type} from "@colyseus/schema";

export class Board extends Schema {
    @type(["number"])
    values: number[];

    @type("number")
    rows: number;

    @type("number")
    cols: number;

    constructor(rows: number = 20, cols: number = 10) {
        super();
        this.rows = rows;
        this.cols = cols;
        this.values = new ArraySchema<number>(...(new Array<number>(rows * cols).fill(0)));
    }
}
Enter fullscreen mode Exit fullscreen mode

Tetrolyso

A Tetrolyso block is basically just an extended version of a Board, having an additional number property storing it’s color. It is skipped here for brevity. Instead, please refer to the available implementation on GitHub.

GameState

What’s more interesting is our overall game state.

import {Schema, type} from "@colyseus/schema";
import {getRandomBlock, Tetrolyso} from "./Tetrolyso";
import {Position} from "./Position";
import {Board} from "./Board";

export class GameState extends Schema {
    @type(Board)
    board: Board;

    @type(Tetrolyso)
    currentBlock: Tetrolyso;

    @type(Position)
    currentPosition: Position;

    @type(Tetrolyso)
    nextBlock: Tetrolyso;

    @type("number")
    clearedLines: number;

    @type("number")
    level: number;

    @type("number")
    totalPoints: number;

    constructor(rows: number = 20, cols: number = 10, initialLevel = 0) {
        super();
        this.board = new Board(rows, cols);
        this.currentBlock = getRandomBlock();
        this.currentPosition = new Position(0, 5);
        this.nextBlock = getRandomBlock();
        this.level = initialLevel;
        this.clearedLines = 0;
        this.totalPoints = 0;
    }
}
Enter fullscreen mode Exit fullscreen mode

It consists of a few number properties but additionally, it possesses several child schema properties to assemble the overall state.
Using such nested child state classes gives us great flexibility when modelling our state. @type annotations provide a simple and type-safe way to enable synchronisation and nested child schema allow us to break our state down which enables re-use.

Once again, if you want to along, the current tag is 02-gamestate in our repository.

git checkout tags/02-gamestate -b 02-gamestate
Enter fullscreen mode Exit fullscreen mode

Working With Game State - Frontend

Now that our first draft of our state is completed, let’s see how we can work with it. We will start with building a frontend for our game, since it allows us to visualise our game state.
Colyseus comes with a JavaScript client which we’re going to use:

npm i colyseus.js
Enter fullscreen mode Exit fullscreen mode

We won’t be using any frontend framework, only plain HTML, CSS and TypeScript, so the only two additional things used to build our frontend will be:

We will include nes.css via CDN, so we only need to add Parcel to our devDependencies:

npm i -D parcel
Enter fullscreen mode Exit fullscreen mode

Just enough to build the following layout:

+----------------------------------------------------------+
|                                                          |
|  Title                                                   |
|                                                          |
+----------------------------------------------------------+
             +--------------------+ +------------+
             |                    | |            |
             |                    | | Score      |
             |                    | |            |
             |                    | +------------+
             |                    | +------------+
             |                    | |            |
             |                    | | Level      |
             |                    | |            |
             |      Playing       | +------------+
             |      Field         | +------------+
             |                    | |            |
             |                    | | Next Piece |
             |                    | |            |
             |                    | +------------+
             |                    |
             |                    |
             |                    |
             |                    |
             |                    |
             |                    |
             +--------------------+
Enter fullscreen mode Exit fullscreen mode

The HTML representation of our layout looks like this:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Tetrolyseus</title>
    <link href="https://unpkg.com/nes.css@2.3.0/css/nes.min.css" rel="stylesheet"/>
    <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
    <link rel="stylesheet" type="text/css" href="index.css">
</head>
<body>
<div class="nes-container is-dark with-title">
    <p class="title">Tetrolyseus</p>
    <p>A cooperative approach to the famous blocks game.</p>
</div>
<div id="playingfield">
    <div id="board" class="nes-container is-rounded is-dark"></div>
    <div id="infobox">
        <div class="nes-container is-dark with-title">
            <p class="title">Score</p>
            <p id="score"></p>
        </div>
        <div class="nes-container is-dark with-title">
            <p class="title">Level</p>
            <p id="level"></p>
        </div>
        <div class="nes-container is-dark with-title">
            <p class="title">Next</p>
            <div id="preview"></div>
        </div>
    </div>
</div>
</body>
<script src="index.ts" type="application/javascript"></script>
</html>
Enter fullscreen mode Exit fullscreen mode

Connecting To The Backend

First of all, we’re going to establish a connection to our backend:

document.addEventListener('DOMContentLoaded', async () => {
    const client = new Client(process.env.TETROLYSEUS_SERVER || 'ws://localhost:2567');

    ...
});
Enter fullscreen mode Exit fullscreen mode

Once connected, we can now join or create a game room:

const room: Room<GameState> = await client.joinOrCreate<GameState>("tetrolyseus");
Enter fullscreen mode Exit fullscreen mode

The name we’re providing to joinOrCreate must be one of the game rooms defined on or backend. As its name might imply, joinOrCreate will either join an existing room instance, or create a new one. Besides that, it’s also possible to explicitly create or join a room.
In return, joinOrCreate provides us a Room instance holding our GameState, giving us access to our Board, the current Tetrolyso, its current Position and so on. Everything we need to render our game!

Game Rendering

Now that we have access to our current GameState, we’re able to render our UI. Using CSS Grid and our Board state, we can draw our playing field:

const drawBoard = (board: Board): void => {
    const boardElement = queryBoardElement();
    const elementRect = boardElement.getBoundingClientRect();
    const blockHeight = Math.floor((elementRect.height - 32) / board.rows);
    boardElement.style.gridTemplateColumns = `repeat(${board.cols}, ${blockHeight}px)`;
    boardElement.style.gridTemplateRows = `repeat(${board.rows}, ${blockHeight}px)`;
    boardElement.style.height = "fit-content";
    boardElement.style.width = "fit-content";

    const boardPosition = queryByRowAndColumn(board);

    for (let row = 0; row < board.rows; ++row) {
        for (let col = 0; col < board.cols; ++col) {
            const cellDiv = document.createElement("div");
            cellDiv.id = `cell-r${row}-c${col}`
            cellDiv.style.background = `#${boardPosition(row, col).toString(16)}`;
            boardElement.append(cellDiv);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Given our two-dimensional grid, we can also display the current Tetrolyso:

const drawTetrolyso = (currentBlock: Tetrolyso, currentPosition: Position) => {
    const blockPosition = queryByRowAndColumn(currentBlock);

    for (let row = currentPosition.row; row < currentPosition.row + currentBlock.rows; ++row) {
        for (let col = currentPosition.col; col < currentPosition.col + currentBlock.cols; ++col) {
            if (blockPosition(row - currentPosition.row, col - currentPosition.col) !== 0) {
                const boardSquare = <HTMLDivElement>document.querySelector(`#cell-r${row}-c${col}`);
                boardSquare.style.background = `#${currentBlock.color.toString(16)}`;
                boardSquare.style.border = `1px solid black`;
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Receiving State Updates

So far, we are able to render our UI given the current state. However, to get our game moving we have to re-render our UI every time our state changes.
Rooms provide certain events we can attach a callback to, so we can attach our rendering code to the onStateChange handler:

room.onStateChange((newState: GameState) => {
    clearBoard();
    clearPreview();
    drawBoard(newState.board);
    drawPreview(newState.nextBlock);
    drawTetrolyso(newState.currentBlock, newState.currentPosition);
    drawScore(newState.totalPoints);
    drawLevel(newState.level);
});
Enter fullscreen mode Exit fullscreen mode

Handling Player Input

At this point you might be wondering when we’re going to implement some game logic to e.g. move our Tetrolyso around, check collisions and so on.
Long story short - we won’t! At least not in our frontend. Our UI should serve a single purpose: rendering our state. State manipulations should happen in our backend.
Whenever one of our players hits a key, we send a message to our backend describing what we want to do, e.g. move or rotate the current block. If our game rules allow us to carry out our desired action, the game state will be updated and our frontend will re-render the UI due to this state change.

document.addEventListener('keydown', (ev: KeyboardEvent) => {
    if (ev.code === "Space") {
        room.send("rotate", {});
    } else if (ev.code === "ArrowLeft") {
        room.send("move", LEFT);
    } else if (ev.code === "ArrowRight") {
        room.send("move", RIGHT);
    } else if (ev.code === "ArrowDown") {
        room.send("move", DOWN);
    }
});
Enter fullscreen mode Exit fullscreen mode

room.send allows us to pass messages from our client to our server. keydown events on one of our arrow keys will instruct our backend to move the current Tetrolyso either left, right or down, hitting space will rotate it.

Frontend Wrap-Up

Our declarative approach to game logic keeps our frontend simple and allows us to focus on what we want to achieve: rendering our game state.
The last thing we’re going to add here is an npm script to build our frontend:

"scripts": {
  "start:frontend": "parcel frontend/index.html"
},
Enter fullscreen mode Exit fullscreen mode

The current frontend state can be found in tag 03-frontend.

git checkout tags/03-frontend -b 03-frontend
Enter fullscreen mode Exit fullscreen mode

Working With Game State - Backend

Ok, time to get started with our game backend. But before we continue writing code, let’s move our existing code to a dedicated subfolder called backend.

backend
├── TetrolyseusRoom.ts
└── index.ts
Enter fullscreen mode Exit fullscreen mode

We will start our backend via the start:backend npm script:

"scripts": {
  "start:backend": "ts-node backend/index.ts",
  "start:frontend": "parcel frontend/index.html"
},    
Enter fullscreen mode Exit fullscreen mode

Initialising State

Now that everything is in place, let’s continue extending our TetrolyseusRoom. Being a stateful room, the first thing we’re going to do is to initialise our state:

import {Client, Room} from "colyseus";
import {GameState} from "../state/GameState";

export class TetrolyseusRoom extends Room<GameState> {
    onCreate(options: any) {
        this.setState(new GameState())
    }

    onJoin(client: Client, options: any) {
    }

    onLeave(client: Client, consented: boolean) {
    }

    onDispose() {
    }
}
Enter fullscreen mode Exit fullscreen mode

We haven’t changed much so far, but if we start both our backend and frontend, we should be presented with our game board, showing the level, score, our current Tetrolyso and the next one. Everything rendered based on our initialised state.

Scoring

Next, let’s compute our score for clearing lines following the Nintendo scoring system.

const baseScores: Map<number, number> = new Map<number, number>([
    [0, 0],
    [1, 40],
    [2, 100],
    [3, 300],
    [4, 1200]
]);

export const computeScoreForClearedLines = (clearedLines: number, level: number): number => {
    return baseScores.get(clearedLines) * (level + 1);
}
Enter fullscreen mode Exit fullscreen mode

The scoring implementation is tagged at 04-scoring.

git checkout tags/04-scoring -b 04-scoring
Enter fullscreen mode Exit fullscreen mode

Detecting Collisions

Our blocks are represented by a series of 0’s and 1’s along with row and column information. When visualised, a Z block looks like the following in our game:

+--------+
|110||001|
|011||011|
|000||010|
+--------+
Enter fullscreen mode Exit fullscreen mode

As we can see, due to their shape, some blocks may have empty rows or columns. When it comes to collision detection we have to make up for these empty values, otherwise we won’t be able to use up all the space of our board.
A simple way to accomplish this is to determine the offset by which a blocks exceeds the board and check if any non-zero “block-element” lies within this range.

   +-------------------------+
   |                         |
   |                         |
   |                         |
+-------+                    |
|00|1100|                    |
|00|1100|                    |
|00|1111|                    |
|00|1111|                    |
|00|1100|                    |
|00|1100|                    |
+-------+                    |
   |                         |
Enter fullscreen mode Exit fullscreen mode
export const isLeftOutOfBounds = (board: Board, tetrolyso: Tetrolyso, position: Position): boolean => {
    if (position.col >= 0) {
        return false;
    }

    const blockElement = queryByRowAndColumn(tetrolyso);

    const offset = -position.col;
    for (let col = 0; col < offset; ++col) {
        for (let row = 0; row < tetrolyso.rows; ++row) {
            if (blockElement(row, col) !== 0) {
                return true;
            }
        }
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

The same scheme applies for collision checks on the bottom and right side of our board.

Checking whether our current block collides with any of the already existing blocks in our board is quite similar as well. We just check for overlapping non-zero elements between our board and the current block to determine collisions:

export const collidesWithBoard = (board: Board, tetrolyso: Tetrolyso, position: Position): boolean => {
    const blockElement = queryByRowAndColumn(tetrolyso);
    const boardElement = queryByRowAndColumn(board);

    for (let boardRow = position.row; boardRow < position.row + tetrolyso.rows; ++boardRow) {
        for (let boardCol = position.col; boardCol < position.col + tetrolyso.cols; ++boardCol) {
            const blockRow = boardRow - position.row;
            const blockCol = boardCol - position.col;
            if (blockElement(blockRow, blockCol) !== 0 && boardElement(boardRow, boardCol) !== 0) {
                return true;
            }
        }
    }
    return false;
}
Enter fullscreen mode Exit fullscreen mode

The completed collision detection implementation is tagged at 05-collision.

git checkout tags/05-collision -b 05-collision
Enter fullscreen mode Exit fullscreen mode

Making Our Game Work - Game Logic

Until now our game has been rather static. Instead of moving blocks we just witnessed a single, static block which didn’t move.
Before we can get things moving, we have to define some rules our game has to follow. In other words, we have to implement our game logic, which sums up to the following steps:

  • Calculate next position of falling block
  • Detect collisions and either move the current block or freeze it at its current position
  • Determine completed lines
  • Update scores
  • Update board (remove completed lines, add empty ones)
  • Check whether we reached the next level

Game logic implemented in our room re-uses functionality from 05-collision to update our state:

detectCompletedLines() {
    let completedLines = [];
    for (let boardRow = this.state.board.rows - 1; boardRow >= 0; --boardRow) {
        if (isRowEmpty(this.state.board, boardRow)) {
            break;
        }

        if (isRowCompleted(this.state.board, boardRow)) {
            completedLines.push(boardRow);
        }
    }
    return completedLines;
}

updateBoard(completedLines: number[]) {
    for (let rowIdx = 0; rowIdx < completedLines.length; ++rowIdx) {
        deleteRowsFromBoard(this.state.board, completedLines[rowIdx] + rowIdx);
        addEmptyRowToBoard(this.state.board);
    }
}

dropNewTetrolyso() {
    this.state.currentPosition = new Position(
        0,
        5
    );
    this.state.currentBlock = this.state.nextBlock.clone();
    this.state.nextBlock = getRandomBlock();
}

moveOrFreezeTetrolyso(nextPosition: Position) {
    if (
        !isBottomOutOfBounds(this.state.board, this.state.currentBlock, nextPosition) &&
        !collidesWithBoard(this.state.board, this.state.currentBlock, nextPosition)
    ) {
        this.state.currentPosition = nextPosition;
    } else {
        freezeCurrentTetrolyso(this.state.board, this.state.currentBlock, this.state.currentPosition);
        this.dropNewTetrolyso();
        this.checkGameOver();
    }
}
Enter fullscreen mode Exit fullscreen mode

Full game logic is tagged at 06-game-logic.

git checkout tags/06-game-logic -b 06-game-logic
Enter fullscreen mode Exit fullscreen mode

Making Our Game Run - Game Loop

Great, we have our game logic set up! Now, let’s assemble our game loop to get things running!

Our game loop performs all the steps we listed in the previous section:

loopFunction = () => {
    const nextPosition = this.dropTetrolyso();
    this.moveOrFreezeTetrolyso(nextPosition);

    const completedLines = this.detectCompletedLines();
    this.updateClearedLines(completedLines);
    this.updateTotalPoints(completedLines);
    this.updateBoard(completedLines);
    this.checkNextLevel();
}
Enter fullscreen mode Exit fullscreen mode

We will use a Delayed instance for our game clock:

gameLoop!: Delayed;
Enter fullscreen mode Exit fullscreen mode

Our onCreate handler will start the loop:

onCreate(options: any) {
    ...
    const loopInterval = 1000 / (this.state.level + 1);
    this.gameLoop = this.clock.setInterval(this.loopFunction, loopInterval);
    ...
}
Enter fullscreen mode Exit fullscreen mode

So our blocks will initially drop at one row per second, becoming faster as we level up.

In case we reached the next level, we restart our loop:

checkNextLevel() {
    const nextLevel = this.determineNextLevel();
    if (nextLevel > this.state.level) {
        this.state.level = nextLevel;
        this.gameLoop.clear();
        const loopInterval = 1000 / (this.state.level + 1);
        this.gameLoop = this.clock.setInterval(this.loopFunction, loopInterval);
    }
}
Enter fullscreen mode Exit fullscreen mode

The last thing missing in our onCreate are message handlers. Our frontend communicates with our backend via messages. So if we want to be able to rotate or move our blocks, our backend has to process these messages accordingly.

onCreate(options: any) {
    ...
    this.onMessage("rotate", (client, _) => {
        const rotatedBlock = this.state.currentBlock.rotate();
        const rotatedPosition = keepTetrolysoInsideBounds(this.state.board, rotatedBlock, this.state.currentPosition);
        if (!collidesWithBoard(this.state.board, rotatedBlock, rotatedPosition)) {
            this.state.currentBlock = rotatedBlock;
            this.state.currentPosition = rotatedPosition;
        }
    });
    this.onMessage("move", (client, message: Movement) => {
        const nextPosition = new Position(
            this.state.currentPosition.row + message.row,
            this.state.currentPosition.col + message.col
        );
        if (
            !isLeftOutOfBounds(this.state.board, this.state.currentBlock, nextPosition) &&
            !isRightOutOfBounds(this.state.board, this.state.currentBlock, nextPosition) &&
            !isBottomOutOfBounds(this.state.board, this.state.currentBlock, nextPosition) &&
            !collidesWithBoard(this.state.board, this.state.currentBlock, nextPosition)
        ) {
            this.state.currentPosition = nextPosition;
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

At this point, we should be able to play a game of Tetrolyseus. And if we open our frontend multiple time, we’re also already able to move and rotate our block from multiple sessions!

If you want to jump straight to this point, you can check out tag 07-game-loop.

git checkout tags/07-game-loop -b 07-game-loop
Enter fullscreen mode Exit fullscreen mode

Multiplayer?

Now that we’re able to actually play Tetrolyseus, there’s one question left:

What’s the multiplayer approach?

Tetrolyesues implements a multiplayer mode which allows one player to only move a block and the other one is only able to rotate it. We will keep a list of current players and assign them the respective player type:

export enum PlayerType {
    MOVER,
    ROTATOR
}

export class Player {
    constructor(public readonly id: string, private _ready: boolean, private readonly _type: PlayerType) {
    }

    public get isReady(): boolean {
        return this._ready
    }
    public set isReady(isReady: boolean) {
        this._ready = isReady;
    }
    public isMover(): boolean {
        return this._type === PlayerType.MOVER;
    }
    public isRotator(): boolean {
        return this._type === PlayerType.ROTATOR;
    }
}
Enter fullscreen mode Exit fullscreen mode

Our room holds a map of players

playerMap: Map<string, Player>;
Enter fullscreen mode Exit fullscreen mode

and this map will be used in both onJoin and onLeave handlers:

onJoin(client: Client, options: any) {
    if (!this.playerMap.size) {
        const playerType = Math.random() >= 0.5 ? PlayerType.MOVER : PlayerType.ROTATOR;
        this.playerMap.set(client.id, new Player(client.id, false, playerType));
    } else {
        if (this.roomHasMover()) {
            this.playerMap.set(client.id, new Player(client.id, false, PlayerType.ROTATOR));
        } else {
            this.playerMap.set(client.id, new Player(client.id, false, PlayerType.MOVER));
        }
    }
}

onLeave(client: Client, consented: boolean) {
    this.playerMap.delete(client.id);
}
Enter fullscreen mode Exit fullscreen mode

This map will be used to limit actions to the respective player in our onMessage handlers:

this.onMessage("move", (client, message: Movement) => {
    if (this.playerMap.has(client.id)) && this.playerMap.get(client.id).isMover()) {
        ...
Enter fullscreen mode Exit fullscreen mode
this.onMessage("rotate", (client, _) => {
    if (this.playerMap.has(client.id) && this.playerMap.get(client.id).isRotator()) {
        ...
Enter fullscreen mode Exit fullscreen mode

The first joining player will be assigned to be a MOVER or ROTATOR at random, the other player will take the other role.

Are We Ready Yet?

Until now our game loop started with the creation of our room. This imposes a bit of a problem for the first joining player, being only able to either move or rotate a block.

To mitigate this circumstance, let’s add a running flag to our GameState:

@type("boolean")
running: boolean;
Enter fullscreen mode Exit fullscreen mode

Additionally, we’re gonna introduce a new message type, ReadyState:

export interface ReadyState {
    isReady: boolean;
}

export const READY = {
    isReady: true
}

export const NOT_READY = {
    isReady: false
}
Enter fullscreen mode Exit fullscreen mode

The message handler for our ReadyState will update our Players’ state and once all roles have been assign and every player is ready, we will start the game loop:

onCreate(options: any) {
    ...
    this.onMessage("ready", (client, message: ReadyState) => {
        if (this.playerMap.has(client.id)) {
            this.playerMap.get(client.id).isReady = message.isReady;
        }

        if (this.roomHasMover() && this.roomHasRotator() && this.allPlayersReady()) {
            this.state.running = true;
            this.startGameLoop();
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Our frontend will display a modal to set yourself ready:

<div class="nes-container is-dark with-title">
    <p class="title">Tetrolyseus</p>
    <p>A cooperative approach to the famous blocks game.</p>
</div>
<div id="ready-modal" class="nes-container is-rounded is-dark with-title">
    <p class="title">Ready to play?</p>
    <label>
        <input id="ready" type="radio" class="nes-radio is-dark" name="answer-dark" checked />
        <span>Yes</span>
    </label>

    <label>
        <input id="not-ready" type="radio" class="nes-radio is-dark" name="answer-dark" />
        <span>No</span>
    </label>
</div>
<div id="playingfield">
...
Enter fullscreen mode Exit fullscreen mode

A click on one of the buttons will send the respective ReadyState message to our backend:

document.addEventListener('DOMContentLoaded', async () => {
    ...

    const readyModal = queryReadyModal();
    const readyButton = queryReadyButton();
    const notReadyButton = queryNotReadyButton();

    readyButton.addEventListener("click", () => room.send("ready", READY));
    notReadyButton.addEventListener("click", () => room.send("ready", NOT_READY));

    room.onStateChange((newState: GameState) => {
        if (newState.running) {
            if (!(typeof document.onkeydown === "function")) {
                document.addEventListener('keydown', handleInput);
            }
            readyModal.style.display = "none";
            renderGame(newState);
        } else {
            document.removeEventListener('keydown', handleInput);
        }
    });
});
Enter fullscreen mode Exit fullscreen mode

Once the game is running, the modal will be hidden and the game is on!

If you want to check out the game right away, use tag 08-multiplayer.

git checkout tags/08-multiplayer -b 08-multiplayer
Enter fullscreen mode Exit fullscreen mode

Ready To Ship?

That’s it, we’re ready to get our game out there!
One last thing to do are some additional scripts to create an application bundle for easier shipping. Let’s extend our package.json:

"scripts": {
  ...
  "build:backend": "tsc -p tsconfig.json",
  "build:frontend": "parcel build frontend/index.html",
  "clean": "rimraf ./dist ./app",
  "bundle": "npm run clean && npm run build:backend && npm run build:frontend && ncp dist/ app/public"
  ...
  },
Enter fullscreen mode Exit fullscreen mode

We can instruct our backend express instance to also serve our frontend by adding the following config in backend/index.ts:

const app = express()

const staticPath = join(__dirname, '../public');
console.log(`Using static path '${staticPath}'`);
app.use(express.static(staticPath));

app.use(cors());
Enter fullscreen mode Exit fullscreen mode

Running npm run bundle will create an application bundle in app:

app
├── backend
├── messages
├── public
└── state
Enter fullscreen mode Exit fullscreen mode

The last tag to check out is 09-app-bundle.

git checkout tags/09-app-bundle -b 09-app-bundle
Enter fullscreen mode Exit fullscreen mode

Summary

In this post we built a fully working multiplayer game from scratch without caring all too much about networking. Colyseus really keeps it out of our way and allows us to fully focus on our game!
Since great gameplay is what gets people hooked to our games, this is a really nice solution for building online multiplayer games!

Where To Go From Here?

Colyseus has a lot more to offer than we covered here.
Some of the things we didn’t touch so far are:

  • Social log-in
  • Password protected rooms
  • Configuring rooms
  • Handling drop-outs / reconnets

Another thing we could extend our game with would of course be a highscore list. Lot’s of space for improvements!

💖 💪 🙅 🚩
s1hofmann
Simon Hofmann

Posted on November 7, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related