How to turn a React.js game into multiplayer with Colyseus

Lucid Sight, Inc.
Colyseus
Published in
9 min readDec 8, 2022

Today we are going to modify an existing single-player game — originally made by @pmndrs — and turn it into a multiplayer experience.

The original game uses React and react-three-fiber.

Throughout this tutorial, we will:

  1. Create a Colyseus server
  2. Send local player position to the server (player positioning will be client-authoritative)
  3. Render the remote players on the game scene

References:

Step 1: Understanding the original game (single-player)

Firstly, let’s analyze the source code of the racing-game project and learn how it handles player movement.

From the App.tsx file, we can observe that a Vehicle component is being used for the local player logic and representation:

1 <Vehicle angularVelocity={[...angularVelocity]} position={[...position]} rotation={[...rotation]}>

The Vehicle component is responsible for handling keyboard events for movement and rendering the 3d model of the vehicle and its wheels as separate components - Chassis and Wheel.

We are going to continue using Vehicle original implementation to represent the current player - additionally, during every render frame we will need to let the server know our final position (x, y, z) and rotation - so other players can render us. A new component for “other” players will need to be created.

Step 2: Creating the server

In order to receive connections and their respective positions, we first need to create a Colyseus server. From your terminal, run this command:

1npm init colyseus-app ./my-colyseus-server 2cd my-colyseus-server

When asked, choose the TypeScript template as it’s the language we’re going to be using during this tutorial.

Make sure you have Node.js LTS installed on your development environment

To start the server, run npm start.

Defining the synchronizable structures (aka Schema)

Colyseus uses its Schema data structures to allow for real-time data synchronization across the connected players. (See documentation)

The structures we are going to need are a Map of players — containing their respective positions and rotations.

For simplicity’s sake, we are going to define a single Schema model to represent both position (x, y, z) and rotation (x, y, z, w).

1// {SERVER_ROOT}/src/rooms/schema/MyRoomState.ts 2import { Schema, type } from '@colyseus/schema' 3 4export class AxisData extends Schema { 5 @type('number') 6 x: number = 0 7 8 @type('number') 9 y: number = 0 10 11 @type('number') 12 z: number = 0 13 14 @type('number') 15 w: number = 0 16}

Now let’s define our Player structure using the AxisData in order to hold its position and rotation:

1// {SERVER_ROOT}/src/rooms/schema/MyRoomState.ts 2// ... 3export class Player extends Schema { 4 @type('string') 5 sessionId = '' 6 7 @type(AxisData) 8 position: AxisData = new AxisData() 9 10 @type(AxisData) 11 rotation: AxisData = new AxisData() 12}

Finally, and most importantly, we need a root state that ties it all together, defining a Map that will contain every connected player:

1// {SERVER_ROOT}/src/rooms/schema/MyRoomState.ts 2// ... 3export class MyRoomState extends Schema { 4 @type({ map: Player }) 5 players = new MapSchema<Player>() 6}

We’re going to use these structures later during this tutorial, mostly within your room game logic at {SERVER_ROOT}/src/rooms/MyRoom.ts.

Step 3: Integrating Colyseus client into the game

In order to connect with the server, you will need to install the Colyseus SDK in your client project.

In a new terminal tab, run the following:

1npm install --save colyseus.js

If you’re unsure how to install the JavaScript/TypeScript SDK into your application, check out the documentation here.

Now, we are going to define the function to initialize the connection with the Colyseus server. For this, let’s create a new file under src/network/api.ts:

1// {CLIENT_ROOT}/src/network/api.ts 2import { Client, Room } from 'colyseus.js' 3 4// import state type directly from the server code 5import type { MyRoomState } from '{SERVER_ROOT}/src/rooms/schema/GameRoomState' 6 7const COLYSEUS_HOST = 'ws://localhost:2567' 8const GAME_ROOM = 'my_room' 9 10export const client: Client = new Client(COLYSEUS_HOST) 11export let gameRoom: Room<MyRoomState> 12 13export const joinGame = async (): Promise<Room> => { 14 gameRoom = await client.joinOrCreate<MyRoomState>(GAME_ROOM) 15 return gameRoom 16} 17 18export const initializeNetwork = function () { 19 return new Promise<void>((resolve, reject) => { 20 joinGame() 21 .then((room) => { 22 gameRoom = room 23 gameRoom.state.players.onAdd = (player, sessionId) => { 24 if (sessionId === gameRoom.sessionId) { 25 gameRoom.state.players.onAdd = undefined 26 resolve() 27 } 28 } 29 }) 30 .catch((err) => reject(err)) 31 }) 32} 33

The sessionId is a unique identifier provided by Colyseus server that represents our current game session. We are going to use this value to differentiate which player instance is our current player.

Let’s call initializeNetwork now from our application’s entrypoint at main.tsx. In case initializeNetwork fails to connect, we are going to render a “network failure“ message for the end-user.

1// {CLIENT_ROOT}/src/main.tsx 2import { createRoot } from 'react-dom/client' 3import { useGLTF, useTexture } from '@react-three/drei' 4import 'inter-ui' 5import './styles.css' 6import App from './App' 7import { initializeNetwork } from './network/api' 8 9// ... 10 11const defaultStyle = { color: 'green', paddingLeft: '2%' } 12const errorStyle = { color: 'red', paddingLeft: '2%' } 13 14const root = createRoot(document.getElementById('root')!) 15 16root.render( 17 <div style={defaultStyle}> 18 <h2>Establishing connection with server...</h2> 19 </div>, 20) 21 22initializeNetwork() 23 .then(() => { 24 root.render(<App />) 25 }) 26 .catch((e) => { 27 console.error(e) 28 root.render( 29 <div style={errorStyle}> 30 <h2>Network failure!</h2> 31 <h3>Is your server running?</h3> 32 </div>, 33 ) 34 }) 35

Great! We now have our client successfully establishing a connection with the server.

Yet, there is still no client-server communication happening at this point. Let’s change that!

Step 4: Spawning the player from the server

Now we are going to make each new player start at a randomized position. The server will be responsible for that instead of the client.

The position set by the server is going to be synchronized with all connected players.

1// {SERVER_ROOT}/src/rooms/GameRoom.ts 2// ... 3 4onJoin(client: Client, options: any) { 5 // Initialize dummy player positions 6 const newPlayer = new Player() 7 newPlayer.sessionId = client.sessionId 8 9 newPlayer.position.x = -generateRandomInteger(109, 115) 10 newPlayer.position.y = 0.75 11 newPlayer.position.z = generateRandomInteger(215, 220) 12 13 newPlayer.rotation.w = 0 14 newPlayer.rotation.x = 0 15 newPlayer.rotation.y = Math.PI / 2 + 0.35 16 newPlayer.rotation.z = 0 17 18 this.state.players.set(client.sessionId, newPlayer) 19} 20// ...

(Because we are assigning this data to the room’s state, new players joining the room at a later stage are also going to receive this data)

Now, on the client side, we are going to use the position received by the server for our main player.

1// {CLIENT_ROOT}/src/App.tsx 2// ... 3 4const room = gameRoom 5const currentPlayer = room.state.players.get(room.sessionId)! 6 7// ... 8 9<Vehicle 10 key={currentPlayer.sessionId} 11 angularVelocity={[0, 0, 0]} 12 position={[currentPlayer.position.x, currentPlayer.position.y, currentPlayer.position.z]} 13 rotation={[0, Math.PI / 2 + 0.33, 0]}> 14 {light && <primitive object={light.target} />} 15 <Cameras /> 16</Vehicle> 17 18// ...

Step 5: Continuously sending current player’s position to the server

From the client side, in the Chassis model we are going to send the current player’s position and rotation at every frame to the server as a message.

1// {CLIENT_ROOT}/src/models/vehicle/Chassis.tsx 2 3import { gameRoom } from '../../network/api' 4// ... 5 6useFrame((_, delta) => { 7 // ... 8 9 if (chassis_1.current.parent) { 10 const _position = new Vector3() 11 chassis_1.current.getWorldPosition(_position) 12 13 const _rotation = new Quaternion() 14 chassis_1.current.getWorldQuaternion(_rotation) 15 16 gameRoom.send('movementData', { 17 position: { x: _position.x, y: _position.y, z: _position.z }, 18 rotation: { w: _rotation.w, x: _rotation.x, y: _rotation.y, z: _rotation.z } 19 }) 20 } 21})

This approach is client-authoritative — when the client dictates the truth of the server’s state.

Now, from the server, we are going to update the position of the sender’s player representation based on the sender’s sessionId.

To be able to process the message sent by the client, we are going to define a handler for the "movementData" message type.

As the server has no knowledge of what a “current” player is, we will need to determine which Player instance we are going to be manipulating based on the sessionIdof the client that originally sent the message:

1// {SERVER_ROOT}/src/rooms/GameRoom.ts 2 onCreate(options: any) { 3 this.setState(new GameRoomState()) 4 5 this.onMessage('movementData', (client, data) => { 6 const player = this.state.players.get(client.sessionId) 7 if (!player) { 8 console.warn("trying to move a player that doesn't exist", client.sessionId) 9 return 10 } 11 12 player.position.x = data.position.x 13 player.position.y = data.position.y 14 player.position.z = data.position.z 15 16 player.rotation.w = data.rotation.w 17 player.rotation.x = data.rotation.x 18 player.rotation.y = data.rotation.y 19 player.rotation.z = data.rotation.z 20 }) 21 }

Step 6: Visual representation of opponent players

For opponent players, and for simplicity’s sake, we are going to duplicate the main player’s Vehicle and Chassis components - naming them as OpponentVehicle and OpponentChassis respectively, and removing unnecessary functionality such as keyboard events, positioning the camera, etc.

See final version of OpponentVehicle and OpponentChasis on GitHub.

Step 7: Spawning the opponent players

We will be creating a new component for the list of opponents. This will ensure only the list of opponents is re-rendered whenever a player joins or leaves the game.

The Colyseus client triggers its schema callbacks in real time as data is changed from the server side. We are going to attach onAdd and onRemove callbacks to force re-rendering the opponent list as players join and leave the game room.

It wouldn’t be optimal to attach the onAdd and onRemove callbacks at every render. So, we’ll also use the useLayoutEffect(() => {}, []) hook to make sure they’re attached only once:

1// {CLIENT_ROOT}/src/network/OpponentListComponent.tsx 2import React, { useLayoutEffect, useState } from 'react' 3 4import type { Player } from './api' 5import { gameRoom } from './api' 6import { OpponentVehicle } from '../models/vehicle/OpponentVehicle' 7 8export function OpponentListComponent() { 9 const room = gameRoom 10 11 function getOpponents() { 12 const opponents: Player[] = [] 13 14 room.state.players.forEach((opponent, sessionId) => { 15 // ignore current/local player 16 if (sessionId === room.sessionId) { 17 return 18 } 19 opponents.push(opponent) 20 }) 21 22 return opponents 23 } 24 25 const [otherPlayers, setOtherPlayers] = useState(getOpponents()) 26 27 useLayoutEffect(() => { 28 let timeout: number 29 30 room.state.players.onAdd = (_, key) => { 31 // use timeout to prevent re-rendering multiple times 32 window.clearTimeout(timeout) 33 timeout = window.setTimeout(() => { 34 // skip if current/local player 35 if (key === room.sessionId) { 36 return 37 } 38 39 setOtherPlayers(getOpponents()) 40 }, 50) 41 } 42 43 room.state.players.onRemove = (player) => setOtherPlayers(otherPlayers.filter((p) => p !== player)) 44 }, []) 45 46 return ( 47 <group> 48 {otherPlayers.map((player) => { 49 return ( 50 <OpponentVehicle 51 key={player.sessionId} 52 player={player} 53 playerId={player.sessionId} 54 angularVelocity={[0, 0, 0]} 55 position={[player.position.x, player.position.y, player.position.z]} 56 rotation={[player.rotation.x, player.rotation.y, player.rotation.z]} 57 ></OpponentVehicle> 58 ) 59 })} 60 </group> 61 ) 62} 63

Now that we have our opponents list component ready, let’s use it besides the main Vehicle in the main App.tsx component:

1// {CLIENT_ROOT}/src/App.tsx 2// ... 3 <ToggledDebug scale={1.0001} color="white"> 4 { 5 <Vehicle 6 key={currentPlayer.sessionId} 7 angularVelocity={[0, 0, 0]} 8 position={[currentPlayer.position.x, currentPlayer.position.y, currentPlayer.position.z]} 9 rotation={[0, Math.PI / 2 + 0.33, 0]} 10 > 11 {light && <primitive object={light.target} />} 12 <Cameras /> 13 </Vehicle> 14 } 15 <OpponentListComponent /> 16 <Train /> 17// ...

Great! Now we can see opponent players joining and leaving. Almost multiplayer!

Step 7: Moving opponent players

The client-side is already receiving the most up-to-date position and rotation from every opponent player, but we’re not doing anything with this data yet.

Let’s update the opponent’s visual representation from OpponentChassis component that we created earlier, and continuously update the opponent’s position and rotation based on the value received from the server.

We are going to use the useFrame hook for that:

1// {CLIENT_ROOT}/src/models/vehicle/OpponentChassis.tsx 2// ... 3import type { Player } from '../../network/api' 4 5export const OpponentChassis = forwardRef<Group, PropsWithChildren<BoxProps & { player: Player }>>( 6 7 // ... 8 9 const player = props.player 10 11 useFrame((/*_, delta*/) => { 12 chassis_1.current.material.color.set('maroon') 13 14 // Set synchronized player movement for the frame 15 api.quaternion.set(player.rotation.x, player.rotation.y, player.rotation.z, player.rotation.w) 16 api.position.set(player.position.x, player.position.y, player.position.z) 17 }) 18 19// ...

Yay! Now every opponent will be moving according to the Player schema changes from the server.

Step 8: Opponent players in the “mini-map“

One last thing we can do is display a different cursor for each other player on the “mini-map”. To keep this tutorial short we’re not going into detail here. The approach is very similar to how we did the opponent list, though. Please take a look at the final Minimap.tsx implementation here.

And that’s a wrap!

We hope this tutorial was helpful to you and that you learned something new! See you in the next one!

Follow Colyseus on Twitter, Join the discussion on Discord

--

--