Simon Hofmann I live and work in Munich, currently with a focus on Node and TypeScript. I worked in Python and Java in the past. My experience spans frontend and backend development, test automation, and cloud and container platforms.

How to make multiplayer game networking like child’s play

15 min read 4372

Multiplayer Game Networking Is Child's Play

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 initializer that automates the creation of new projects.

npm init colyseus-app ./my-colyseus-app

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

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

Once completed, we’ll 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

Let’s 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 and 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,
});

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

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

const gameServer = new Server();

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

We made a custom demo for .
No really. Click here to check it out.

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

// skipped for brevity

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

Colyseus uses the notion of rooms to implement game logic. A room is defined on our server by its unique name, which clients use to connect to it. A room handles client connections and also holds the game’s state. It ‘ the central piece of our game.

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() {
  }
}

As you can see, a handful lifecycle events are attached to a Colyseus room:

  • onCreate is the first method to be called when a room is instantiated. We will initialize our game state and wire up our message listeners in onCreate
  • onJoin is called as soon a new client connects to our game room
  • onLeave is the exact opposite of onJoin, so whenever a client leaves, disconnect and reconnection logic is handled here
  • onDispose is the last method to be called right before a game room is disposed of and where things such as storing game results to a database might be carried out
  • Although not included in the default room implementation, onAuth allows us to implement custom authentication methods for joining clients, as shown in the authentication API docs

Now that we’ve walked through a basic Colyseus backend setup, let’s start modeling 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

Managing game state

In one way or another, every game is holding state. Player position, current score, you name it. State makes up 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 we also have to think about how we’re going to synchronize 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 synchronization so we can focus on what matters: the 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 initialize our state.

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

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

  ...
}

Every time a client connects to our room, it will receive the full room state in an initial synchronization 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 (20fps). Shorter intervals allow for faster-paced games.

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;
    }
}

Our state class has to fulfill certain properties to be eligible for synchronization:

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

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

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, which represents 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 Array datatype 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)));
    }
}

Tetrolyso

A Tetrolyso block is basically just an extended version of a board with an additional number property storing its color. We’ll skip it here for brevity’s sake. For more info, you can 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;
    }
}

It consists of a few number properties. Additionally, it possesses several child schema properties to assemble the overall state.

Using such nested child state classes gives us great flexibility when modeling our state. @type annotations provide a simple and type-safe way to enable synchronization and nested child schema allows us to break our state down, which enables reuse.

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

git checkout tags/02-gamestate -b 02-gamestate

Working with game state: Frontend

Now that the first draft of our state is completed, let’s see how we can work with it. We’ll start by building a frontend for our game, which will allow us to visualize our game state.

Colyseus comes with a JavaScript client:

npm i colyseus.js

We won’t be using any frontend framework, only plain HTML, CSS, and TypeScript. The only two additional things we’ll use to build our frontend are NES.css and Parcel.js.

We’ll include NES via CDN, so we only need to add Parcel to our devDependencies.

npm i -D parcel

Just enough to build the following layout:

+----------------------------------------------------------+
|                                                          |
|  Title                                                   |
|                                                          |
+----------------------------------------------------------+
             +--------------------+ +------------+
             |                    | |            |
             |                    | | Score      |
             |                    | |            |
             |                    | +------------+
             |                    | +------------+
             |                    | |            |
             |                    | | Level      |
             |                    | |            |
             |      Playing       | +------------+
             |      Field         | +------------+
             |                    | |            |
             |                    | | Next Piece |
             |                    | |            |
             |                    | +------------+
             |                    |
             |                    |
             |                    |
             |                    |
             |                    |
             |                    |
             +--------------------+

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>

Connecting to the backend

Let’s establish a connection to our backend.

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

    ...
});

Once connected, we can join or create a game room.

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

The name we’re providing to joinOrCreate must be one of the game rooms defined on or backend. As its name implies, joinOrCreate either joins an existing room instance or creates a new one. Besides that, it’s also possible to explicitly create or join a room.

In return, joinOrCreate provides a Room instance holding our GameState, which gives 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 can 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);
        }
    }
}

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`;
            }
        }
    }
}

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 rerender our UI every time our state changes.

Rooms provide certain events to which we can attach a callback, 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);
});

Handling player input

At this point, you might be wondering when we’re going to implement some game logic to 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);
    }
});

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’ll add here is an npm script to build our frontend.

"scripts": {
  "start:frontend": "parcel frontend/index.html"
},

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

git checkout tags/03-frontend -b 03-frontend

Working with game state: Backend

It’s 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

We’ll start our backend via the start:backend npm script.

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

Initializing state

Now that everything is in place, let’s further extend our TetrolyseusRoom. Since it’s a stateful room, the first thing we’ll do is to initialize 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() {
    }
}

We haven’t changed much so far, but if we start both our backend and frontend, we should be presented with our game board. This will show the level, score, and the current and next Tetrolysos. Everything is rendered based on our initialized state.

Scoring

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);
}

The scoring implementation is tagged at 04-scoring.

git checkout tags/04-scoring -b 04-scoring

Detecting collisions

Our blocks are represented by a series of 0s and 1s, along with row and column information. When visualized, a Z block looks like the following.

+--------+
|110||001|
|011||011|
|000||010|
+--------+

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 block exceeds the board and check whether any nonzero block element lies within this range.

   +-------------------------+
   |                         |
   |                         |
   |                         |
+-------+                    |
|00|1100|                    |
|00|1100|                    |
|00|1111|                    |
|00|1111|                    |
|00|1100|                    |
|00|1100|                    |
+-------+                    |
   |                         |
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;
}

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

The process of checking whether the current block collides with any of the existing blocks on the board is quite similar. Simply check for overlapping nonzero elements between the 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;
}

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

git checkout tags/05-collision -b 05-collision

Game logic

Until now, our game has been rather static. Instead of moving blocks, we just witnessed a single, static block that didn’t move.

Before we can get things moving, we have to define some rules our game has to follow. In other words, we need to implement our game logic, which involves the following steps.

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

The game logic implemented in our room reuses functionality from 05-collision to update the 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();
    }
}

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

git checkout tags/06-game-logic -b 06-game-logic

Game loop

Now that we have our game logic set up, let’s assemble a 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();
}

We will use a Delayed instance for the game clock.

gameLoop!: Delayed;

The onCreate handler will start the loop.

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

The blocks will initially drop at one row per second, becoming faster as we level up. If we reach 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);
    }
}

The only thing missing in onCreate are message handlers. The frontend communicates with the 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;
        }
    });
}

At this point, we should be able to play a game of Tetrolyseus. If we open the frontend multiple times, we can also move and rotate a 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

Making it multiplayer

With our Tetrolyseus game up and running, there’s one question left: What’s the multiplayer approach?

Tetrolyesues implements a multiplayer mode that allows one player to only move a block while the other can only rotate it. We’ll keep a list of current players and assign each a 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;
    }
}

Our room holds a map of players:

playerMap: Map<string, Player>;

Tthis map is used in both the 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);
}

The map limits player actions in the onMessage handlers.

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

The first player to join is assigned to be a MOVER or ROTATOR at random, the next player is assigned the other role, and so on.

Ready to play?

Up to this point, our game loop started with the creation of our room. This poses a bit of a problem for the first joining player, who is only able to either move or rotate a block.

To address this, let’s add a running flag to our GameState.

@type("boolean")
running: boolean;

Additionally, we’ll introduce a new message type: ReadyState.

export interface ReadyState {
    isReady: boolean;
}

export const READY = {
    isReady: true
}

export const NOT_READY = {
    isReady: false
}

The message handler for our ReadyState will update the players’ stat. Once all roles have been assigned and all players are ready, welll 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();
        }
    });
}

The frontend will display a modal prompting players to set themselves as 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">
...

A button click sends 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);
        }
    });
});

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

Ready to ship?

We’re finally ready to get our game out there! Let’s tack on some additional scripts to create an application bundle for easier shipping.

First, we’ll 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"
  ...
  },

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());

Running npm run bundle creates an application bundle in app:

app
├── backend
├── messages
├── public
└── state

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

git checkout tags/09-app-bundle -b 09-app-bundle

Summary

In this tutorial, we built a fully functional multiplayer game from scratch without worrying too much about networking. Colyseus really keeps it out of our way and allows you to fully focus on your game. Since great gameplay is what ultimately gets people hooked on games, this is a really nice solution for building online multiplayer games.

Where do you go from here?

Colyseus has a lot more to offer than what we covered here. Some features we didn’t have time to touch on include:

  • Social login
  • Password-protected rooms
  • Configuring rooms
  • Handling dropouts/reconnets

The logical next step would be to add a high score list. Now that you have a basic multiplayer game to build upon and improve, the sky’s the limit!

You come here a lot! We hope you enjoy the LogRocket blog. Could you fill out a survey about what you want us to write about?

    Which of these topics are you most interested in?
    ReactVueAngularNew frameworks
    Do you spend a lot of time reproducing errors in your apps?
    YesNo
    Which, if any, do you think would help you reproduce errors more effectively?
    A solution to see exactly what a user did to trigger an errorProactive monitoring which automatically surfaces issuesHaving a support team triage issues more efficiently
    Thanks! Interested to hear how LogRocket can improve your bug fixing processes? Leave your email:

    : Full visibility into your web apps

    LogRocket is a frontend application monitoring solution that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

    In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

    You come here a lot! We hope you enjoy the LogRocket blog. Could you fill out a survey about what you want us to write about?

      Which of these topics are you most interested in?
      ReactVueAngularNew frameworks
      Do you spend a lot of time reproducing errors in your apps?
      YesNo
      Which, if any, do you think would help you reproduce errors more effectively?
      A solution to see exactly what a user did to trigger an errorProactive monitoring which automatically surfaces issuesHaving a support team triage issues more efficiently
      Thanks! Interested to hear how LogRocket can improve your bug fixing processes? Leave your email:

      200’s only Monitor failed and slow network requests in production

      Deploying a Node-based web app or website is the easy part. Making sure your Node instance continues to serve resources to your app is where things get tougher. If you’re interested in ensuring requests to the backend or third party services are successful, try LogRocket. https://logrocket.com/signup/

      LogRocket is like a DVR for web apps, recording literally everything that happens on your site. Instead of guessing why problems happen, you can aggregate and report on problematic network requests to quickly understand the root cause.

      LogRocket instruments your app to record baseline performance timings such as page load time, time to first byte, slow network requests, and also logs Redux, NgRx, and Vuex actions/state. .
      .
      Simon Hofmann I live and work in Munich, currently with a focus on Node and TypeScript. I worked in Python and Java in the past. My experience spans frontend and backend development, test automation, and cloud and container platforms.

      Leave a Reply