Node.js Server With Colyseus: MTG Room & WebSocket Fun
Hey guys! Ready to dive into the world of real-time multiplayer gaming? We're gonna build a cool server using Node.js, Colyseus, and TypeScript. Think of it as the backbone for a simple Magic: The Gathering (MTG) style room, where players can hang out, and we'll test out some fun WebSocket stuff. This whole project focuses on setting up the server, defining shared data types, and making sure our WebSocket communication works like a charm. Let's get started, shall we?
Setting Up the Node.js and Colyseus Server
First things first, let's get our server environment ready. We'll be using Node.js, a runtime environment that allows us to execute JavaScript code outside a web browser, and TypeScript, which adds types to our JavaScript code, making it easier to manage and debug. We'll also be using Colyseus, a framework specifically designed for creating real-time multiplayer games. Colyseus simplifies the process of setting up rooms, handling player connections, and managing game state.
To kick things off, create a new project directory and initialize a new Node.js project using npm init -y. This command creates a package.json file where we'll manage our project dependencies. Next, install the necessary packages: npm install colyseus @colyseus/tools. We're installing Colyseus itself and the Colyseus tools. After that, we'll install TypeScript: npm install -D typescript @types/node. The -D flag indicates that these are development dependencies. We'll need @types/node to provide type definitions for Node.js.
Next, let's set up the basic structure of our server. Create an apps/server directory. Inside this directory, create the following files: server.ts (where our server logic will reside) and tsconfig.json (to configure TypeScript). In the tsconfig.json file, we need to specify how our TypeScript code should be compiled. A basic configuration would look something like this:
{
"compilerOptions": {
"target": "ES2019",
"module": "commonjs",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["./**/*"]
}
This configuration specifies that our code should be compiled to ES2019, uses CommonJS modules, outputs the compiled code to a dist directory, enables ES module interop, enforces consistent casing in file names, enables strict type checking, and skips type checking of library files. Create the server.ts file. In this file, we'll write the code to create our Colyseus server. We'll also add a /health endpoint that returns a 200 OK status to indicate that the server is running. This is super helpful for checking if our server is up and running!
import { Server } from "colyseus";
import { WebSocketTransport } from "@colyseus/ws-transport";
import http from "http";
import { MTGRoom } from "./rooms/MTGRoom";
const port = parseInt(process.env.PORT || "2567");
const gameServer = new Server({
transport: new WebSocketTransport({
server: http.createServer().listen(port)
})
});
gameServer.define("mtg", MTGRoom);
gameServer.onShutdown(function() {
console.log("closing all connections...");
gameServer.connections.forEach( (client) => {
client.close();
});
console.log("all connections closed!");
});
gameServer.onShutdown(function() {
console.log("closing all connections...");
gameServer.connections.forEach( (client) => {
client.close();
});
console.log("all connections closed!");
});
console.log(`Listening on ws://localhost:${ port }`);
This basic setup creates a Colyseus server, sets up a WebSocket transport, defines a room called "mtg" using our MTGRoom (which we'll define later), and logs a message to the console indicating that the server is listening. We also included some basic shutdown logic. Cool, right?
Creating the MTG Room and Health Check
Okay, let's create the MTGRoom. This is where the magic of our MTG-style game logic will happen. This room will handle player connections, disconnections, and manage the game state. Create a directory called rooms inside the apps/server directory, and create a file called MTGRoom.ts inside the rooms directory. Inside MTGRoom.ts, we'll define the MTGRoom class, extending Colyseus's Room class. This is where we define how players join, leave, and how the game state is managed.
import { Room, Client } from "colyseus";
import { RoomState, Opcode } from "../packages/types";
export class MTGRoom extends Room<RoomState> {
onCreate (options: any) {
console.log("MTGRoom created!");
this.setState(new RoomState());
this.onMessage("ping", (client: Client, message: {data: string}) => {
console.log("Received ping from", client.sessionId, ":", message);
this.broadcast("ping", {data: message.data}, {except: client});
});
}
onJoin (client: Client, options: any) {
console.log(client.sessionId, "joined!");
}
onLeave (client: Client, consented: boolean) {
console.log(client.sessionId, "left!");
}
onDispose() {
console.log("room destroyed!");
}
}
This code sets up the basic structure of our MTGRoom. The onCreate method is called when the room is created. Inside this method, we set the initial state of the room using this.setState(new RoomState()). We also set up a message handler for the "ping" message. When a client sends a "ping" message, the server logs the message and then broadcasts it back to all other clients, except for the one that sent it. The onJoin and onLeave methods are called when a player joins and leaves the room, respectively. The onDispose method is called when the room is destroyed.
Next, let's create the /health endpoint to check if the server is up and running. To do this, we'll modify the server.ts file to include an HTTP server and create a route for the /health endpoint. This endpoint will simply return a 200 OK status with a JSON payload indicating that the server is alive.
import { Server } from "colyseus";
import { WebSocketTransport } from "@colyseus/ws-transport";
import http from "http";
import { MTGRoom } from "./rooms/MTGRoom";
import express from "express";
const port = parseInt(process.env.PORT || "2567");
const app = express();
app.get("/health", (req, res) => {
res.status(200).json({ status: "alive" });
});
const gameServer = new Server({transport: new WebSocketTransport({ server: http.createServer(app).listen(port) })});
gameServer.define("mtg", MTGRoom);
gameServer.onShutdown(function() {
console.log("closing all connections...");
gameServer.connections.forEach( (client) => {
client.close();
});
console.log("all connections closed!");
});
console.log(`Listening on ws://localhost:${ port }`);
This update adds an express app, defines a /health endpoint that returns a JSON response with a status of "alive", and sets up the server to listen on the specified port. This way, we can check if our server is up and responding by simply sending a request to /health.
Defining Shared Types with TypeScript
One of the coolest parts about using TypeScript is its ability to define shared types. We'll create a separate module that exports interfaces for Card, Player, and RoomState, as well as a type for Opcode. This ensures type safety between the server and the client. To do this, we'll create a packages/types directory and a file called index.ts inside it. In this file, we'll define the interfaces and type.
export interface Card {
name: string;
description: string;
}
export interface Player {
id: string;
name: string;
cards: Card[];
}
export interface RoomState {
players: { [id: string]: Player };
cards: Card[];
}
export type Opcode = "mv" | "rt" | "tp" | "fl" | "zn";
This code defines the interfaces and type that we'll use throughout our project. The Card interface represents a Magic: The Gathering card, the Player interface represents a player, the RoomState interface represents the state of the room, and the Opcode type represents the different operations that can be performed in the game. To use these types in our MTGRoom.ts file, we simply need to import them:
import { Room, Client } from "colyseus";
import { RoomState, Opcode } from "../packages/types";
By using these shared types, we can ensure that our server and client code are consistent and that our data is type-safe. This makes it easier to debug our code and prevents errors from occurring.
Setting Up WebSocket Communication
Now, let's establish some basic WebSocket communication. We'll implement a simple ping-pong mechanism to test if our WebSocket connection is working and to ensure that the server is correctly echoing messages back to the clients. This will help us verify the round-trip functionality of our WebSocket connection. First, we need to handle the initial connection, then send a message, and receive the response. In the MTGRoom.ts file, we've already included the logic to handle the "ping" message. When a client sends a "ping" message, the server logs the message and then broadcasts it back to all other clients, except for the one that sent it. This setup is perfect for testing our WebSocket connection and confirming that it’s correctly sending and receiving messages. This simple round-trip test is a great way to verify that your WebSocket connection is stable.
On the client-side (which we won't fully implement here, but imagine it), when the client connects to the WebSocket endpoint (/play), the client sends a message containing {op: "ping"}. This message is then received by the server. The server then echoes this message back to all connected clients (except the one that sent it) to confirm that the WebSocket is working correctly.
import { Room, Client } from "colyseus";
import { RoomState, Opcode } from "../packages/types";
export class MTGRoom extends Room<RoomState> {
onCreate (options: any) {
console.log("MTGRoom created!");
this.setState(new RoomState());
this.onMessage("ping", (client: Client, message: {data: string}) => {
console.log("Received ping from", client.sessionId, ":", message);
this.broadcast("ping", {data: message.data}, {except: client});
});
}
onJoin (client: Client, options: any) {
console.log(client.sessionId, "joined!");
}
onLeave (client: Client, consented: boolean) {
console.log(client.sessionId, "left!");
}
onDispose() {
console.log("room destroyed!");
}
}
This simple ping-pong mechanism verifies that the client is able to send messages to the server, and the server is able to receive and process those messages, then echo them back. This confirms that the WebSocket communication is working as expected. This approach gives us confidence in the reliability of our communication channel. The "ping" and "pong" messages serve as a fundamental check of connectivity and data transfer between the client and the server.
Running the Server and Testing
Now that we've set everything up, let's run our server and test it! First, build your TypeScript code by running tsc in your project's root directory. This will compile your TypeScript files into JavaScript files in the dist directory. Then, run the server using node dist/server.js. The server should start and listen for incoming connections. To test the /health endpoint, you can use a tool like curl or a web browser to send a GET request to http://localhost:2567/health. You should receive a JSON response with the status of "alive". This confirms that your server is running and responding to requests.
Next, to test the WebSocket connection and ping-pong mechanism, you'd need to create a client (e.g., in a separate HTML file with JavaScript or in a Node.js script using a WebSocket client library) that connects to the server's WebSocket endpoint (e.g., ws://localhost:2567). The client sends a "ping" message and logs the response that it receives from the server. If the client receives a "ping" message back, it confirms that the WebSocket connection and message handling are working correctly. This process validates that the client and server can communicate bidirectionally, and that the server is correctly handling incoming messages and echoing them back, as intended. This will give us confirmation that the WebSocket connection is working as expected. With these tests, you can have confidence in the stability of your WebSocket connection.
Conclusion and Next Steps
Awesome work, guys! We've built a basic Node.js server with Colyseus, set up an MTG-style room, defined shared types using TypeScript, and implemented a simple WebSocket round-trip test. We've ensured type safety between the client and server and verified basic communication. This is a solid foundation for building a more complex real-time multiplayer game. What's next? You could implement game logic, add more features to your MTG room, create a client-side interface, and expand the functionality of your game.
Some potential next steps include adding support for more game actions (like drawing cards, playing cards, and combat), implementing a user interface, adding more complex game state management, and expanding the game's features. You could also explore more advanced Colyseus features like matchmaking, room persistence, and client-side prediction. Keep experimenting and building to further your skills. The possibilities are endless!
I hope you enjoyed this tutorial. Happy coding, and have fun building your own real-time multiplayer game!