December 14, 2024 6 minutes

Part 1: Basic Player Movement

This guide will show you how you can build a multiplayer experience with Colyseus Multiplayer Framework and Phaser.

In Part 1, you will learn how to:

  • Set up a Colyseus server and the Phaser client
  • Connect multiple players into a room
  • Use keyboard arrow keys to move players across the network

Materials

Part 1: Phaser Scene source-code
Part 1: Colyseus Room source-code


Full source-code on GitHub
Live Project on Glitch

1. Before you start

Prior Knowledge Expected

Software requirements

Node.js LTS
Visual Studio Code

2. Creating the Server

We will be making a basic server, hosted locally on your computer for keeping player states.

To create a fresh new Colyseus server, run the following from your command-line:

npm init colyseus-app ./server

Let’s make sure you can run the server locally now, by running npm start:

cd server
npm start

If successful, the output should look like this in your command-line:

> my-app@1.0.0 start
> ts-node-dev --respawn --transpile-only src/index.ts

✅ development.env loaded.
✅ Express initialized
🏟 Your Colyseus App
⚔️ Listening on ws://localhost:2567

3. Creating the Client

We are going to set up a new project using NPM, Parcel and TypeScript for the client-side. If you are more comfortable with other tooling, feel free to use the tools of your choice instead.

Setting up Parcel and TypeScript

Create the client-side project by running the following commands from your terminal:

mkdir client
cd client
npm init -y

Install the development dependencies (parcel and typescript):

npm install --save-dev parcel typescript

Install the runtime dependencies (phaser and colyseus.js):

npm install --save phaser colyseus.js

Generate the tsconfig.json file by running the following command:

npx tsc --init

IMPORTANT: By default, the generated tsconfig.json file has strict type checking enabled. We suggest disabling strict type checking for this tutorial.

// (...)
    /* Type Checking */
    "strict": false,
// (...)

Create the index.html file, importing our entrypoint file as a module:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Colyseus + Phaser Example</title>
</head>
<body>
    <!---------------------------------------------------->
    <!-- Include entrypoint TypeScript file as a module -->
    <!---------------------------------------------------->
    <script src="index.ts" type="module"></script>
</body>
</html>

And, finally, create the entrypoint index.ts file TypeScript file, creating a Phaser.Game instance, and a GameScene:

import Phaser from "phaser";

// custom scene class
export class GameScene extends Phaser.Scene {
    preload() {
      // preload scene
    }

    create() {
      // create scene
    }

    update(time: number, delta: number): void {
      // game loop
    }
}

// game config
const config: Phaser.Types.Core.GameConfig = {
    type: Phaser.AUTO,
    width: 800,
    height: 600,
    backgroundColor: '#b6d53c',
    parent: 'phaser-example',
    physics: { default: "arcade" },
    pixelArt: true,
    scene: [ GameScene ],
};

// instantiate the game
const game = new Phaser.Game(config);

In order to access the client-side project from your web browser, let’s start parcel through another terminal tab, which is going to build and serve the client-side files:

npx parcel serve index.html

Alternatively, you can edit your package.json file and add a "start" command with this:

  // (...)
  "scripts": {
    "start": "parcel serve index.html",
  },
  // (...)

Then, instead of running that first long parcel command, you can run npm start for short now:

npm start

If successful, the output should look like this in your command-line:

> client@1.0.0 start
> parcel serve index.html

Server running at http://localhost:1234
✨ Built in 6ms

4. Establishing a Client-Server Connection

From the Phaser scene, let’s instantiate our Colyseus Client instance, and connect into a Room.

We need the create() method to be defined as async to be able to use await inside it.

import { Client, Room } from "colyseus.js";

// custom scene class
export class GameScene extends Phaser.Scene {
    // (...)

    client = new Client("ws://localhost:2567");
    room: Room;

    async create() {
      console.log("Joining room...");

      try {
        this.room = await this.client.joinOrCreate("my_room");
        console.log("Joined successfully!");

      } catch (e) {
        console.error(e);
      }
    }

    // (...)
}

Note that we’re using the local ws://localhost:2567 endpoint here. You need to deploy your server to the public internet in order to play with others online. You can also use Glitch to host your server publicly.

When you refresh your browser now, your client is going to establish a connection with the server, and the server is going to create the room my_room on demand for you.

Notice that my_room is the default room identifier set by the barebones Colyseus server. You can and should change this identifier in the arena.config.ts file.

You will be seeing the following message in your server logs, which means a client successfully joined the room!

19U8WkmoK joined!

5. Room State and Schema

In Colyseus, we define shared data through its Schema structures.

Schema is a special data type from Colyseus that is capable of encoding its changes/mutations incrementally. The encoding and decoding process happens internally by the framework and its SDK.

The state synchronization loop looks like this:

  1. State changes (mutations) are synchronized automatically from Server → Clients
  2. Clients, by attaching callbacks to their local read-only Schema structures, can observe for state mutations and react to it.
  3. Clients can send arbitrary messages to the server - which decides what to do with it - and may mutate the state (Go back to step 1.)

Let’s go back to editing the Server code, and define our Room State in the Server.

We need to handle multiple Player instances, and each Player will have x, y and z coordinates:

// MyRoomState.ts
import { MapSchema, Schema, type } from "@colyseus/schema";

export class Player extends Schema {
    @type("number") x: number;
    @type("number") y: number;
}

export class MyRoomState extends Schema {
    @type({ map: Player }) players = new MapSchema<Player>();
}

See more about the Schema structures.

Now, still in the server-side, let’s modify our onJoin() method to create a Player instance whenever a new connection is established with the room.

// MyRoom.ts
// (...)
    onJoin(client: Client, options: any) {
        console.log(client.sessionId, "joined!");

        const mapWidth = 800;
        const mapHeight = 600;

        // create Player instance
        const player = new Player();

        // place Player at a random position
        player.x = (Math.random() * mapWidth);
        player.y = (Math.random() * mapHeight);

        // place player in the map of players by its sessionId
        // (client.sessionId is unique per connection!)
        this.state.players.set(client.sessionId, player);
    }
// (...)
}

Also, when the client disconnects, let’s remove the player from the map of players:

// MyRoom.ts
// (...)
    onLeave(client: Client, consented: boolean) {
        console.log(client.sessionId, "left!");

        this.state.players.delete(client.sessionId);
    }
// (...)

The state mutations we’ve done on the server-side can be observed on the client-side, and that’s what we’re going to do in the next sections.

6. Preloading Assets in the Demo Scene

For this demo, we only need one sprite to represent the player, which we are going to use whenever a player joins the room.

Player Sprite

    // (...)

    preload() {
      // preload scene
      this.load.image('ship_0001', 'https://cdn.glitch.global/3e033dcd-d5be-4db4-99e8-086ae90969ec/ship_0001.png');
    }

    // (...)

7. Listening for State Changes

After a connection with the room has been established, the client-side can start listening for state changes, and create a visual representation of the data in the server.

Adding new players

As per Room State and Schema section, whenever the server accepts a new connection - the onJoin() method is creating a new Player instance within the state.

We’re going to listen to this event on the client-side now:

// (...)
this.room.state.players.onAdd((player, sessionId) => {
  //
  // A player has joined!
  //
  console.log("A player has joined! Their unique session id is", sessionId);
});
// (...)

When playing the Scene, you should see a message in the console of the browser whenever a new client joins the room.

For the visual representation, we need to add a Phaser GameObject for each joining player, and keep a local reference to the GameObject based on their sessionId, so we can operate on them later:

export class GameScene extends Phaser.Scene {
    // (...)
    room: Room;

    // we will assign each player visual representation here
    // by their `sessionId`
    playerEntities: {[sessionId: string]: any} = {};

    // (...)
    async create() {
        // (...)

        // listen for new players
        this.room.state.players.onAdd((player, sessionId) => {
            const entity = this.physics.add.image(player.x, player.y, 'ship_0001');

            // keep a reference of it on `playerEntities`
            this.playerEntities[sessionId] = entity;
        });

        // (...)
    }
}

Removing disconnected players

When a player is removed from the state (upon onLeave() in the server-side), we need to remove their visual representation as well.

// (...)
this.room.state.players.onRemove((player, sessionId) => {
    const entity = this.playerEntities[sessionId];
    if (entity) {
        // destroy entity
        entity.destroy();

        // clear local reference
        delete this.playerEntities[sessionId];
    }
});
// (...)

8. Moving the players

Sending input messages

For this particular example, we are going to send the player’s input at every tick. This is a requirement for the client-side prediction technique applied by the end of this tutorial.

At every update() tick, we are going to update the local inputPayload, and send it as a message to the server.

export class GameScene extends Phaser.Scene {
    // (...)

    // local input cache
    inputPayload = {
        left: false,
        right: false,
        up: false,
        down: false,
    };

    cursorKeys: Phaser.Types.Input.Keyboard.CursorKeys;

    preload() {
        // (...)
        this.cursorKeys = this.input.keyboard.createCursorKeys();
    }

    update(time: number, delta: number): void {
        // skip loop if not connected with room yet.
        if (!this.room) { return; }

        // send input to the server
        this.inputPayload.left = this.cursorKeys.left.isDown;
        this.inputPayload.right = this.cursorKeys.right.isDown;
        this.inputPayload.up = this.cursorKeys.up.isDown;
        this.inputPayload.down = this.cursorKeys.down.isDown;
        this.room.send(0, this.inputPayload);
    }
    // (...)

Receiving the message from the server

Whenever the message is received in the server, we’re going to mutate the player that sent the message through its sessionId.

⚠️ Note: We are going to update this method on Part 3. The final implementation with client-side prediction needs to process the input at every tick instead of when receiving the message.

// MyRoom.ts
// (...)
  onCreate(options: any) {
    this.setState(new MyRoomState());

    // handle player input
    this.onMessage(0, (client, payload) => {
      // get reference to the player who sent the message
      const player = this.state.players.get(client.sessionId);
      const velocity = 2;

      if (payload.left) {
        player.x -= velocity;

      } else if (payload.right) {
        player.x += velocity;
      }

      if (payload.up) {
        player.y -= velocity;

      } else if (payload.down) {
        player.y += velocity;
      }
    });
  }
// (...)

Updating Player’s visual representation

Having the mutation on the server, we can detect it on the client-side via player.onChange(), or player.listen().

  • player.onChange() is triggered per schema instance
  • player.listen(prop) is triggered per property change

We are going to use .onChange() since we need all the new coordinates at once, no matter if just one has changed individually.

// (...)
// listen for new players
this.room.state.players.onAdd((player, sessionId) => {
    const entity = this.physics.add.image(player.x, player.y, 'ship_0001');

    // keep a reference of it on `playerEntities`
    this.playerEntities[sessionId] = entity;

    // listening for server updates
    player.onChange(() => {
        // update local position immediately
        entity.x = player.x;
        entity.y = player.y;
    });

    // Alternative, listening to individual properties:
    // player.listen("x", (newX, prevX) => console.log(newX, prevX));
    // player.listen("y", (newY, prevY) => console.log(newY, prevY));
});
// (...)

Read more about Schema callbacks

9. Extra: Monitoring Rooms and Connections

Colyseus comes with an optional monitoring panel that can be helpful during the development of your game.

To view the monitor panel from your local server, go to http://localhost:2567/colyseus.

monitor

You can see and interact with all spawned rooms and active client connections through this panel.

See more information about the monitor panel.

Finished 1 of 4:

Part 1: Basic Player Movement

Next:

2. Part 2: Linear Interpolation

Mark all complete and continue

Continue without marking complete