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.
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.
// 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 roomonLeave
is the exact opposite of onJoin
, so whenever a client leaves, disconnect and reconnection logic is handled hereonDispose
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 outonAuth
allows us to implement custom authentication methods for joining clients, as shown in the authentication API docsNow 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
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.
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:
Schema
base classtype
annotationsetState
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))); } }
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
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/[email protected]/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>
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.
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`; } } } }
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); });
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.
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
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" },
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.
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
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
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.
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
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
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.
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
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
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.
Colyseus has a lot more to offer than what we covered here. Some features we didn’t have time to touch on include:
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!
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.
LogRocket is like a DVR for web and mobile apps, recording literally everything that happens while a user interacts with your app. 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. Start monitoring for free.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to implement one-way and two-way data binding in Vue.js, using v-model and advanced techniques like defineModel for better apps.
Compare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.