Game Servers and Colyseus

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

A game server framework packages the parts of an authoritative multiplayer server that every game needs and that are tedious to get right: rooms with a lifecycle, a matchmaker that places players in them, a state that is diffed and sent as binary patches, reconnection, and a way to run several processes. This page covers Colyseus, the framework most used with browsers and Node.js, applies it to the Pixel Arena scenario of this section, and then covers geckos.io, which solves a different problem (an unreliable, UDP-like transport for a Node.js server) and shows where writing your own loop on uWebSockets.js is the better choice. The game design itself (tick rate, authority, snapshots) is explained in Multiplayer Game Architecture and the techniques that hide latency in Latency Compensation; the complete hand-written build is Building a Multiplayer Game.

Baseline, checked on 2026-10-24: Colyseus 0.18.10 (colyseus and @colyseus/core 0.18.23, @colyseus/schema 5.0.42, @colyseus/sdk 0.18.6, @colyseus/ws-transport 0.18.4, @colyseus/redis-presence 0.18.6 and @colyseus/redis-driver 0.18.7, all MIT licensed) on Node.js 24.21.0, which satisfies the engine requirement of Node.js 22 or later, with Redis 7.4.6; geckos.io @geckos.io/server and @geckos.io/client 3.1.0 (BSD-3-Clause, released 2026-03-27) with node-datachannel 0.32.1. The Colyseus examples were run with a Node.js client and the geckos.io example in headless Chrome.

The Pixel Arena constants used here

The pages about Pixel Arena share the same numbers so that the variants can be compared: an arena of 1280 by 720 pixels, players moving at 240 pixels per second, an authoritative tick of 20 Hz (50 ms), clients that render at 60 Hz and send input batches at 60 Hz, an increasing unsigned 32-bit sequence number on every input, a snapshot rate of 20 Hz that carries lastProcessedInput for each player, an interpolation render delay of 100 ms, and hitscan firing with a 1 second rewind history capped at 200 ms. Everything below keeps these values.

What Colyseus is

Colyseus is a Node.js (and Bun) framework for authoritative multiplayer servers. A client asks the matchmaker to join or create a room of a named type; the matchmaker answers with a seat reservation; the client then opens a WebSocket to the process that hosts the room. In the room, game code runs on the server, mutates a typed state, and the framework sends the changes to every client as small binary patches. The wire is WebSocket only, which is the right trade-off for a browser game that does not need the unreliable delivery of UDP (see Data Channels for that case). The documentation is at docs.colyseus.io; client SDKs exist for TypeScript, Unity, Godot, Defold, Flutter, Swift and others.

The pieces:

  • Server and transport. defineServer takes a transport (WebSocketTransport from @colyseus/ws-transport, or the uWebSockets.js based @colyseus/uwebsockets-transport), the room definitions, and optional presence and driver.

  • Room. A class with a lifecycle (onCreate, onAuth, onJoin, onDrop, onReconnect, onLeave, onDispose), message handlers, a simulation loop, maxClients and patchRate.

  • State. A class built with @colyseus/schema (the schema() builder with t.* field types, or the older decorators). The room assigns it to state; only the fields that changed are encoded in each patch.

  • Matchmaker. Decides which room instance a join request lands in, using filterBy options, sortBy, the room’s metadata and its locked or private flags, with an optional lobby or queue room.

  • Presence and driver. Interfaces for the cross-process data. The default LocalPresence and LocalDriver keep it in memory in a single process; RedisPresence and RedisDriver share it between processes.

  • Client SDK. @colyseus/sdk for browsers and Node.js: Client, joinOrCreate, room.send, room.onMessage, room.state and Callbacks for granular state changes.

Chat and videoconference are served by other tools in this section (Socket.IO, LiveKit and mediasoup). Colyseus is a game framework: it is not an alternative for the Chatterbox protocol, and it ships a WebRTC signalling plugin only as a helper for peer-to-peer games.

Version history and the 0.x caveat

Colyseus has never published a 1.0. Every minor version is a breaking release, and the release notes call them migrations: 0.14 (2020-10-20), 0.15 (2023-05-04), 0.16 (2025-02-17), 0.17 (2026-01-09) and 0.18 (npm release 2026-05-22). The announcement post for 0.18 describes it as the last release before 1.0. Pin exact versions of every @colyseus/* package, upgrade all of them together (the server packages and @colyseus/sdk share one minor version, and @colyseus/schema has its own major version that the migration guide names), and read the migration guide before each bump. What changed recently:

  • 0.17 (migration guide 0.17). The browser package colyseus.js was replaced by @colyseus/sdk (the last colyseus.js is 0.16.22). @colyseus/schema moved from 3 to 4. Protocol became CloseCode, and the Colyseus close codes 4000 to 4010 are reserved, so an application close code in that range must change. The seat reservation lost its nested room object. Room and Client generics changed to the object form Room<{ state: MyState }>. onLeave receives a numeric close code instead of a consented boolean, and a new onDrop hook handles unexpected disconnects. defineServer and defineRoom replaced the config() helper of @colyseus/tools. setSeatReservationTime() became the seatReservationTimeout property.

  • 0.18 (migration guide 0.18). @colyseus/schema moved to 5 and a schema may hold at most 63 fields. Room#setMetadata() now replaces the metadata instead of merging it. client.id is gone (use client.sessionId), @colyseus/fossil-delta-serializer was removed, the Playground’s data endpoints return 404 when NODE_ENV is production, and the password hasher of @colyseus/auth changed so existing email and password users must reset their passwords. New: setTimestep() (the old setSimulationInterval() still works), request and response messages (room.request()), room plugins, and the netcode toolkit described below.

The practical rule: tutorials older than 2026 use colyseus.js, Room<MyState>, client.id and decorator-only schemas, and they will not run unchanged. The metadata change is easy to miss because nothing fails loudly: the Pixel Arena room below first produced two rooms instead of one because of it.

State: the schema

State classes declare each field with a type so that the encoder can send a compact binary patch and the decoder can rebuild the object on the client. The builder form needs no compiler flags. Server and client must declare the same fields in the same order, and a nested object, a map or an array is itself a schema type. The Pixel Arena state holds a map of players keyed by sessionId; the field lastProcessedInput is how a client learns which of its inputs the server has applied, which is what client-side reconciliation needs.

import { schema, t } from "@colyseus/schema";

export const Player = schema({
  name: t.string(),
  x: t.number().default(0),
  y: t.number().default(0),
  hp: t.uint8().default(100),
  lastProcessedInput: t.uint32().default(0),
}, "Player");

export const ArenaState = schema({
  tick: t.uint32().default(0),
  players: t.map(Player),
}, "ArenaState");

Schema 5 field types include string, number (a variable-length numeric type), the fixed-size int8 to int64, uint8 to uint64, float32, float64, boolean, map, array, set and nested schemas. Smaller types matter because the patch is sent 20 times per second to every client: hp as uint8 is one byte. Schema 5 adds quantized floats and angle types, per-field delivery modifiers, and a StateView that restricts which parts of the state a client receives (interest management, for example sending only players near a client). NaN is encoded as 0, multi-dimensional arrays are unsupported, and the 63-field limit is checked when the class is defined.

The room

A room has a lifetime: the matchmaker creates it, clients join and leave, and it is disposed when the last client leaves (autoDispose, on by default). The order is onCreate(options) once, then for every client onAuth, onJoin, optionally onDrop and onReconnect, onLeave, and finally onDispose. onAuth is where a ticket or token is checked and where a join is refused by throwing; its return value arrives as the third argument of onJoin. Authentication design is in Authentication and Authorization.

The Pixel Arena room below keeps the inputs of each player in a queue, and applies them once per 50 ms tick, one 1/60 second step per input, so a client that sends 60 inputs per second moves at exactly 240 pixels per second. Messages are validated because they come from untrusted browsers: an input batch must be an array, each seq an integer, each axis clamped to -1, 0 or 1, and the queue is capped at 120 pending inputs (2 seconds). The fire handler implements hitscan with lag compensation: it keeps one second of position history, rewinds by the rewindMs the client reports, capped at 200 ms, and tests the ray against the rewound positions. Note the setMetadata call, which spreads this.metadata: filterBy stores the join options (here mode) in the metadata, and replacing it would break the matching.

import { Room, ServerError } from "colyseus";
import { ArenaState, Player } from "./state.mjs";

const WIDTH = 1280;
const HEIGHT = 720;
const SPEED = 240;            // px per second
const STEP = 1 / 60;          // one client input = one 60 Hz step
const TICK_MS = 50;           // 20 Hz authoritative tick
const HISTORY_MS = 1000;
const MAX_REWIND_MS = 200;
const HIT_RADIUS = 16;
const MAX_QUEUE = 120;        // refuse to buffer more than 2 s of input

const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));

export class ArenaRoom extends Room {
  maxClients = 8;
  patchRate = TICK_MS;          // one state patch per tick = 20 Hz snapshots
  state = new ArenaState();
  queues = new Map();           // sessionId -> pending inputs
  history = [];                 // [{ t, pos: Map(sessionId -> {x, y}) }]

  onAuth(client, options) {
    if (typeof options?.name !== "string" || !/^[\w-]{1,16}$/.test(options.name)) {
      throw new ServerError(400, "invalid name");
    }
    return { name: options.name };
  }

  onCreate() {
    this.setMetadata({ ...this.metadata, title: "Pixel Arena" });

    this.onMessage("input", (client, batch) => {
      const queue = this.queues.get(client.sessionId);
      if (!queue || !Array.isArray(batch)) return;
      for (const input of batch) {
        if (queue.length >= MAX_QUEUE) break;
        if (!Number.isInteger(input?.seq)) continue;
        queue.push({
          seq: input.seq,
          dx: clamp(Math.round(Number(input.dx) || 0), -1, 1),
          dy: clamp(Math.round(Number(input.dy) || 0), -1, 1),
        });
      }
    });

    this.onMessage("fire", (client, msg) => this.hitscan(client, msg));

    this.setTimestep(() => this.tick(), TICK_MS);
  }

  onJoin(client, options, auth) {
    const p = new Player();
    p.name = auth.name;
    p.x = 100 + Math.random() * (WIDTH - 200);
    p.y = 100 + Math.random() * (HEIGHT - 200);
    this.state.players.set(client.sessionId, p);
    this.queues.set(client.sessionId, []);
  }

  onLeave(client) {
    this.state.players.delete(client.sessionId);
    this.queues.delete(client.sessionId);
  }

  tick() {
    for (const [id, player] of this.state.players) {
      const queue = this.queues.get(id);
      for (const input of queue.splice(0)) {
        const len = Math.hypot(input.dx, input.dy) || 1;
        player.x = clamp(player.x + (input.dx / len) * SPEED * STEP, 0, WIDTH);
        player.y = clamp(player.y + (input.dy / len) * SPEED * STEP, 0, HEIGHT);
        player.lastProcessedInput = input.seq;
      }
    }
    this.state.tick++;

    const now = Date.now();
    const pos = new Map();
    this.state.players.forEach((p, id) => pos.set(id, { x: p.x, y: p.y }));
    this.history.push({ t: now, pos });
    while (this.history.length && now - this.history[0].t > HISTORY_MS) this.history.shift();
  }

  // Nearest history entry at or before (now - rewindMs).
  positionsAt(rewindMs) {
    const target = Date.now() - clamp(Number(rewindMs) || 0, 0, MAX_REWIND_MS);
    let frame = this.history[0];
    for (const f of this.history) if (f.t <= target) frame = f;
    return frame?.pos ?? new Map();
  }

  hitscan(client, msg) {
    const shooter = this.state.players.get(client.sessionId);
    if (!shooter || typeof msg?.angle !== "number") return;
    const world = this.positionsAt(msg.rewindMs);
    const dirX = Math.cos(msg.angle);
    const dirY = Math.sin(msg.angle);
    let best = null;
    for (const [id, p] of world) {
      if (id === client.sessionId) continue;
      const rx = p.x - shooter.x;
      const ry = p.y - shooter.y;
      const along = rx * dirX + ry * dirY;
      if (along <= 0) continue;
      const miss = Math.abs(rx * dirY - ry * dirX);
      if (miss <= HIT_RADIUS && (!best || along < best.along)) best = { id, along };
    }
    if (best) {
      const victim = this.state.players.get(best.id);
      if (victim) victim.hp = Math.max(0, victim.hp - 25);
      this.broadcast("hit", { shooter: client.sessionId, victim: best.id });
    }
  }
}

The properties that control the room:

Property Default Meaning

maxClients

Infinity

The room is locked automatically when it is full.

patchRate

50 (ms)

Interval of the state patches, 20 per second. Set to null to disable automatic patches and send messages instead.

seatReservationTimeout

15 (s)

How long a reserved seat waits for the client to connect before the reservation expires.

autoDispose

true

Dispose the room when the last client leaves.

maxMessagesPerSecond

Infinity

Disconnect a client that sends more messages than this per second. Set it for public servers.

setTimestep(callback, delay) runs a variable-step loop (the default is every 16.6 ms and the callback receives the elapsed time); the example passes 50 ms to run the 20 Hz tick. For a game with client-side prediction, 0.18 adds a netcode toolkit: setFixedTimestep(step, tickRate) runs a constant-step accumulator loop and advertises the rate to the clients, defineInput() declares the per-client input schema and buffers the frames (apply each input once per step, never drain the buffer and simulate only the newest frame, because that breaks reconciliation), allowRewindState() keeps the history for lag compensation, and the client has room.input(), a prediction helper and room.clock. The hand-written queue above shows what these do; use the toolkit when you want the framework to run prediction and reconciliation for you (see Server input and timestep and client prediction).

Reconnection. A mobile client drops often. Define onDrop(client, code) and call this.allowReconnection(client, seconds) there to keep the player’s seat; the client reconnects with the token in room.reconnectionToken, onReconnect runs, and only if the window expires does onLeave run. Without onDrop, onLeave is called for every disconnect.

Server entry point

The server entry composes the transport, the optional Redis objects and the room definitions. filterBy(["mode"]) makes the matchmaker place a join request only in a room created with the same mode; enableRealtimeListing() publishes the room to lobby clients as it changes. The transport pings idle clients every 5 seconds and drops one after three missed pongs, which is the heartbeat that the lifecycle page recommends in Connection Lifecycle, Heartbeats and Reconnection.

import { defineServer, defineRoom } from "colyseus";
import { WebSocketTransport } from "@colyseus/ws-transport";
import { RedisPresence } from "@colyseus/redis-presence";
import { RedisDriver } from "@colyseus/redis-driver";
import { ArenaRoom } from "./arena-room.mjs";

const port = Number(process.env.PORT ?? 2567);
const redis = process.env.REDIS_URL;           // e.g. redis://127.0.0.1:6379

const server = defineServer({
  transport: new WebSocketTransport({ pingInterval: 5000, pingMaxRetries: 3 }),
  ...(redis ? { presence: new RedisPresence(redis), driver: new RedisDriver(redis) } : {}),
  publicAddress: process.env.PUBLIC_ADDRESS,   // per process behind a load balancer, e.g. arena.example.test/2567
  rooms: {
    arena: defineRoom(ArenaRoom).filterBy(["mode"]).enableRealtimeListing(),
  },
});

await server.listen(port);
console.log(`Pixel Arena (Colyseus) on ws://localhost:${port}`);

The dependencies, pinned (the SDK is a development dependency because only the test client uses it):

{
  "name": "pixel-arena-colyseus",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22" },
  "scripts": { "start": "node server.mjs", "test": "node client.mjs" },
  "dependencies": {
    "@colyseus/core": "0.18.23",
    "@colyseus/schema": "5.0.42",
    "@colyseus/ws-transport": "0.18.4",
    "@colyseus/redis-presence": "0.18.6",
    "@colyseus/redis-driver": "0.18.7",
    "colyseus": "0.18.10"
  },
  "devDependencies": { "@colyseus/sdk": "0.18.6" }
}

Run it with PORT=47800 node server.mjs. The default WebSocket transport serves the matchmaker over HTTP on the same port as the room sockets (under /matchmake); browsers must be allowed by your CORS and Origin policy as described in Security.

The client

The SDK hides the two-step join. client.joinOrCreate("arena", options) posts to the matchmaker, receives the seat reservation and opens the room socket. room.state is the decoded state; room.onStateChange fires after each patch, and Callbacks.get(room) gives granular onAdd, onRemove and listen callbacks. room.send(type, payload) encodes the payload with MessagePack. The test client below joins two players, sends 59 inputs in batches every 50 ms, checks that lastProcessedInput reaches 59 and that the player moved 236 pixels (59 steps of 4 pixels), counts the roughly 20 patches per second, and makes one player fire at the other.

import { Client, Callbacks } from "@colyseus/sdk";

const url = process.env.ARENA_URL ?? "ws://localhost:47800";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const alice = new Client(url);
const bob = new Client(url);

const roomA = await alice.joinOrCreate("arena", { name: "alice", mode: "duel" });
const roomB = await bob.joinOrCreate("arena", { name: "bob", mode: "duel" });
roomA.onMessage("hit", () => {});
console.log("same room:", roomA.roomId === roomB.roomId, "sessions:", roomA.sessionId !== roomB.sessionId);

// state callbacks (schema decoded on the client)
const cbA = Callbacks.get(roomA);
let patches = 0;
cbA.onAdd("players", (player, id) => console.log("alice sees player", player.name, id === roomA.sessionId ? "(me)" : ""));
roomA.onStateChange(() => patches++);
roomB.onMessage("hit", (m) => console.log("bob received hit:", m.victim === roomB.sessionId));

await sleep(300);

// 60 Hz input, batched: collect 3 inputs per 50 ms and send one message
let seq = 0, pending = [];
const t0 = Date.now();
const timer = setInterval(() => pending.push({ seq: ++seq, dx: 1, dy: 0 }), 1000 / 60);
const sender = setInterval(() => { roomA.send("input", pending); pending = []; }, 50);
const me = () => roomA.state.players.get(roomA.sessionId);
const x0 = me().x;
await sleep(1000);
clearInterval(timer);
await sleep(120);
clearInterval(sender);
roomA.send("input", pending);
await sleep(200);
console.log("inputs sent:", seq, "lastProcessedInput:", me().lastProcessedInput);
console.log("moved px:", Math.round(me().x - x0), "expected ~", Math.round(seq / 60 * 240), "(clamped at 1280)");
console.log("patches/s ~", Math.round(patches / ((Date.now() - t0) / 1000)));

// hitscan: alice aims at bob
const b = roomA.state.players.get(roomB.sessionId);
const angle = Math.atan2(b.y - me().y, b.x - me().x);
roomA.send("fire", { angle, rewindMs: 100 });
await sleep(300);
console.log("bob hp:", roomB.state.players.get(roomB.sessionId).hp);

await roomA.leave(); await roomB.leave();
await sleep(200);
process.exit(0);

Typical output: both players are in the same room, the player moved 236 pixels for 59 inputs, about 24 patches per second (the 20 Hz patch rate plus the extra patches caused by the join and the leave), the victim received the hit message, and its hp is 75. Registering an onMessage handler for each message type the server can send avoids the SDK warning that no handler is registered.

A browser client is the same code plus rendering: collect input at 60 Hz, send the batch every 50 ms, predict locally, and on each patch drop the buffered inputs up to lastProcessedInput and replay the rest, as shown in Latency Compensation. Interpolate the other players 100 ms behind the newest patch.

Matchmaking

joinOrCreate joins an existing room of that type that is neither locked nor private and has free seats, and creates one otherwise. create always creates, join fails if no room matches, joinById can reach a private room, and reconnect resumes a dropped session. A match is computed from the filterBy options, ordered with sortBy (for example sortBy({ clients: -1 }) fills the fullest room first), and lobby clients can subscribe to the list of rooms with enableRealtimeListing() and the built-in LobbyRoom. A QueueRoom groups players that wait and creates a match room when enough are present. The server can expose fewer methods to browsers with matchMaker.controller.exposedMethods, so that clients cannot create arbitrary rooms. The lobby, the unreliable data-channel variant and this Colyseus variant of Pixel Arena are all assembled in Building a Multiplayer Game.

Scaling with Redis presence and driver

A single process is limited by one JavaScript thread. To use more processes or machines, give every process a shared presence (pub/sub and key-value data used for seat reservations and room lookups) and a shared driver (the room listing used by the matchmaker), and a public address so that a client reaches the process that owns its room. The matchmaker request may reach any process; the seat reservation tells the client which one to connect to. The example above switches to Redis when REDIS_URL is set and reads publicAddress from the environment. Redis and two processes:

name: g6-game-servers-and-colyseus
services:
  redis:
    image: redis:8.8.3-alpine
    container_name: g6-game-servers-and-colyseus-redis
    ports:
      - "47801:6379"
docker compose -p g6-game-servers-and-colyseus up -d
PORT=47802 PUBLIC_ADDRESS=localhost:47802 REDIS_URL=redis://127.0.0.1:47801 node server.mjs &
PORT=47803 PUBLIC_ADDRESS=localhost:47803 REDIS_URL=redis://127.0.0.1:47801 node server.mjs &

With this running, a client that calls joinOrCreate against port 47803 and one that calls it against port 47802 land in the same room, and the socket of the second connects to port 47802 because that process owns the room (as seen in a test run). Without publicAddress the first join failed with seat reservation expired (code 524) because the client tried to connect to the process it had asked, not the one that held the room.

Production notes: put a load balancer or reverse proxy in front with WebSocket upgrade and a long read timeout (the documentation uses NGINX or Traefik and a publicAddress that contains the process port in the path), run one process per core with PM2 in fork mode, not cluster, and keep Redis available, because the presence and driver are shared by every process and joins depend on them. State lives in the process memory of the room, so a process crash ends its rooms. Colyseus Cloud is the commercial hosting that provides these pieces without configuration. Proxy and balancer settings are generic and are in Proxies, Load Balancers and Deployment and Scaling WebSockets.

Common errors and pitfalls

  • Two rooms instead of one. Calling setMetadata in onCreate with a new object replaces the filterBy values in 0.18, and later joins never match. Spread this.metadata.

  • seat reservation expired (code 524). The client could not open the room socket within seatReservationTimeout: wrong publicAddress behind a proxy, a blocked WebSocket upgrade, or the process of the room not reachable.

  • Schema mismatch. Fields must have the same names, types and order on server and client; regenerate or share the definition, and never reorder fields in a deployed game.

  • Trusting the client. Message handlers get any payload a browser can build. Validate types and ranges, cap the queue, authorize in onAuth, and use maxMessagesPerSecond.

  • Heavy state. Every tick of every changed field is encoded for every client; use small numeric types, StateView and patchRate to keep the bandwidth bounded (measure it with the techniques of Load Testing and Capacity Planning).

  • Old tutorials. colyseus.js, client.id, Protocol, setSeatReservationTime and decorator-only code belong to 0.16 and earlier.

  • Custom close codes 4000 to 4010 collide with the codes that Colyseus reserves.

geckos.io: data channels for Node.js

WebSocket runs over TCP, so a lost packet stalls every later one (head-of-line blocking), which hurts a game that sends a fresh snapshot 20 times per second. A WebRTC data channel can be unordered and unreliable (see Data Channels). geckos.io is a small library that gives a Node.js server the browser’s RTCDataChannel through node-datachannel (libdatachannel) and hides the signalling: the client posts its offer over HTTP, the server answers, and ICE runs over UDP. It offers emit and on for named messages, rooms and broadcast, reliable message options, an authorization hook, a configurable UDP portRange and ICE servers. It is not a game framework: no rooms lifecycle, state sync or matchmaking, so you write the loop yourself.

Maintenance status. The repository is not archived, the last release is 3.1.0 on 2026-03-27 and the last commit is the same day, with a handful of open issues. That is maintained but low activity: a library that rarely changes, depending on node-datachannel, which does move. Pin both versions and test on upgrade. It supports Node.js 18 or later and ESM only since version 3.

The example below is the Pixel Arena input and snapshot exchange. The server authorizes the connection with a placeholder ticket, queues inputs, advances the simulation every 50 ms and emits a snapshot to all channels, unreliably by default, with lastProcessedInput per player; a welcome message goes reliably. The browser client is bundled with esbuild.

{
  "name": "pixel-arena-geckos",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": { "node": ">=22" },
  "dependencies": {
    "@geckos.io/server": "3.1.0",
    "@geckos.io/client": "3.1.0"
  },
  "devDependencies": { "esbuild": "0.25.10", "puppeteer-core": "24.22.0" }
}
import http from "node:http";
import { readFile } from "node:fs/promises";
import geckos from "@geckos.io/server";

const SIGNAL_PORT = Number(process.env.SIGNAL_PORT ?? 9208);
const WEB_PORT = Number(process.env.WEB_PORT ?? 8080);
const SPEED = 240, STEP = 1 / 60, TICK_MS = 50;

const server = http.createServer(async (req, res) => {
  const file = req.url === "/client.js" ? "client.bundle.js" : "index.html";
  const type = file.endsWith(".js") ? "text/javascript" : "text/html";
  res.writeHead(200, { "content-type": type });
  res.end(await readFile(new URL(file, import.meta.url)));
});

const io = geckos({
  authorization: async (auth) => (auth === "change-me" ? { name: "player" } : false),
  cors: { origin: `http://localhost:${WEB_PORT}`, allowAuthorization: true },
  portRange: { min: Number(process.env.UDP_MIN ?? 10000), max: Number(process.env.UDP_MAX ?? 10019) },
  iceServers: [],
});

const players = new Map();       // channel.id -> { x, y, lastProcessedInput, queue }

io.onConnection((channel) => {
  players.set(channel.id, { x: 640, y: 360, lastProcessedInput: 0, queue: [] });
  channel.emit("welcome", { id: channel.id }, { reliable: true });

  channel.on("input", (batch) => {
    const p = players.get(channel.id);
    if (!p || !Array.isArray(batch)) return;
    for (const i of batch) if (p.queue.length < 120) p.queue.push(i);
  });

  channel.onDisconnect(() => players.delete(channel.id));
});

setInterval(() => {
  const snapshot = { t: Date.now(), players: {} };
  for (const [id, p] of players) {
    for (const i of p.queue.splice(0)) {
      p.x = Math.min(1280, Math.max(0, p.x + Math.sign(i.dx) * SPEED * STEP));
      p.y = Math.min(720, Math.max(0, p.y + Math.sign(i.dy) * SPEED * STEP));
      p.lastProcessedInput = i.seq;
    }
    snapshot.players[id] = { x: p.x, y: p.y, lastProcessedInput: p.lastProcessedInput };
  }
  io.emit("snapshot", snapshot);          // unreliable, unordered by default
}, TICK_MS);

io.listen(SIGNAL_PORT);                   // signalling (HTTP) port
server.listen(WEB_PORT, () => console.log(`web http://localhost:${WEB_PORT}  signalling :${SIGNAL_PORT}`));
import geckos from "@geckos.io/client";

const channel = geckos({
  url: `${location.protocol}//${location.hostname}`,
  port: Number(new URLSearchParams(location.search).get("port") ?? 9208),
  authorization: "change-me",
  iceServers: [],
});

let seq = 0, pending = [], me = null, last = null, snapshots = 0;
channel.onConnect((error) => {
  if (error) { console.error(error.message); return; }
  channel.on("welcome", (m) => { me = m.id; });
  channel.on("snapshot", (s) => { snapshots++; last = s; });
  setInterval(() => pending.push({ seq: ++seq, dx: 1, dy: 0 }), 1000 / 60);
  setInterval(() => { channel.emit("input", pending); pending = []; }, 50);
  channel.onDisconnect(() => console.log("disconnected"));
});

window.report = () => ({ me, seq, snapshots, mine: last && me && last.players[me] });

To bundle and run: npx esbuild client.mjs --bundle --format=esm --outfile=client.bundle.js, then SIGNAL_PORT=47810 WEB_PORT=47811 UDP_MIN=47820 UDP_MAX=47829 node server.mjs and open http://localhost:47811/?port=47810. In headless Chrome the page received 73 snapshots in about four seconds (20 per second), and lastProcessedInput followed the 60 Hz inputs, so the loop and the unreliable channel work end to end.

Deployment is the main cost. Besides the signalling TCP port, the server needs a range of UDP ports reachable from the clients (the README mentions 1025 to 65535 by default; restrict it with portRange), a public IP or ICE servers for NAT traversal, and for clients on restrictive networks a TURN server, because UDP may be blocked (see ICE, STUN and TURN and Scaling WebRTC and TURN). Many corporate networks and some hosting platforms do not allow inbound UDP at all, and a WebSocket fallback is then needed. WebTransport datagrams (WebTransport and Media over QUIC) are the upcoming alternative that avoids SDP and ICE.

When to write your own loop on uWebSockets.js

Question Colyseus Own loop (uWebSockets.js)

Time to a playable room

Hours: rooms, matchmaking, state sync and a client SDK are included.

Days: you design the protocol, snapshot format, rooms and reconnect handling.

Wire cost

Generic schema patches; compact, but the framework decides the format.

Hand-packed binary, e.g. 4 bytes of tick and 9 bytes per player, as in uWebSockets.js.

Players per process

One JavaScript thread per process runs all its rooms; add processes with Redis.

More headroom per core: native fan-out with topics and backpressure control.

Lag compensation and prediction

Netcode toolkit in 0.18 (check the status of each SDK), or your own.

Entirely yours; maximum control, as in the hand-written build.

Stability of the API

Breaking change at each 0.x release.

Small API surface; you own the code.

Non-browser clients

Official SDKs for Unity, Godot, Flutter and more.

You implement the protocol on every platform.

Choose Colyseus when you want to ship a room-based game quickly, with several client platforms and the usual lobby flow. Write your own loop when the protocol is simple and fixed (Pixel Arena’s input is three bytes), when per-player bandwidth or CPU matters more than development time, when you need a custom wire format or a deterministic engine, or when you do not want to depend on a framework that is still at 0.x. Colyseus can also run on uWebSockets.js through @colyseus/uwebsockets-transport, which gives a middle path. Compare the full set of server options in Choosing a WebSocket Stack and Comparison & Decision Guide.

Security

  • Treat every message as hostile: validate shape, range and rate, and keep the simulation authoritative so that a client cannot set its own position, health or time.

  • Check the Origin of browser joins and authenticate in onAuth; the matchmaking HTTP endpoints are also reachable without a browser. See Authentication and Authorization.

  • Do not expose the Playground, the monitor panel or the admin console on a public port. The monitor and the Playground must sit behind authentication; 0.18 returns 404 for the Playground data endpoints in production unless you add a guard.

  • Limit room creation from clients with matchMaker.controller.exposedMethods and cap maxClients and maxMessagesPerSecond, so one client cannot allocate rooms or flood a room.

  • For geckos.io, the authorization callback is the only gate before the peer connection exists; the UDP port range is open to the Internet, so keep inputs small and validated.