Skip to content

Hosting Colyseus and Node.js game servers

Gameye runs browser multiplayer servers written in Node.js, including Colyseus rooms and plain ws servers. Each match is one Node.js process in its own Gameye session, on a dedicated server in the region you choose. No Gameye SDK is needed.

  1. Write an authoritative server with Colyseus, or with Node.js and ws, that serves HTTP and the WebSocket upgrade on one TCP port.
  2. Package it as a Linux container and push it to Docker Hub or GitHub Container Registry.
  3. Create a Gameye application for the image, with that port registered as TCP.
  4. When your matchmaker forms a match, your backend calls POST /session and sends the players the session’s address.
  5. The process exits when the match ends. That ends the session.

Browser pages served over https need a secure WebSocket, so plan for TLS early.

Colyseus gives you rooms, matchmaking hooks and schema-based state sync. On Gameye, run one Colyseus process per match:

  • Your own matchmaker or backend decides who plays together and starts a Gameye session for the match. Colyseus’s built-in matchmaker then only has to place those players in the single room on that process.
  • Listen on the port registered in your Gameye application. Colyseus defaults to 2567, so either register 2567 or pass your own port.
  • Exit the process when the room is disposed, so the session ends and you stop paying for it.
import { Room } from "colyseus";
export class MatchRoom extends Room {
async onDispose() {
await reportResults(); // tell your backend how the match ended
setImmediate(() => process.exit(0)); // ends the Gameye session
}
}

Colyseus registers its own shutdown routine by default (gracefullyShutdown), so a stop request from Gameye disposes the room before the process exits.

On the client, connect to the session’s address and join the room:

import { Client } from "colyseus.js";
const client = new Client("wss://s-1234.play.yourgame.example");
const room = await client.joinOrCreate("match", { token: joinToken });

The port, container and TLS rules below apply to Colyseus unchanged.

Each Gameye session runs one container, and each container runs one match. The server is the authority. Clients send inputs, and the server simulates on a fixed tick and broadcasts the state. Bind 0.0.0.0, authenticate each join with a token your backend minted, and exit with code 0 when the match ends or when Gameye sends SIGTERM.

// server.js: one match per process
import { WebSocketServer } from "ws";
import { createServer } from "http";
const PORT = Number(process.env.GAME_PORT ?? 8080);
const TICK_HZ = 30;
const httpd = createServer((req, res) => { res.writeHead(200); res.end("ok"); });
const wss = new WebSocketServer({ server: httpd });
wss.on("connection", (ws) => {
const playerId = authenticate(ws); // check a join token your backend minted
if (!playerId) return ws.close(4001);
ws.on("message", (m) => onInput(playerId, m)); // inputs only, never outcomes
ws.on("close", () => onLeave(playerId));
});
setInterval(() => { // the server simulates and broadcasts
simulate(1 / TICK_HZ);
broadcast(wss, snapshot());
if (matchOver()) endMatch();
}, 1000 / TICK_HZ);
async function endMatch() {
await reportResults();
process.exit(0); // ends the Gameye session
}
process.on("SIGTERM", endMatch); // a stop request: drain and exit
httpd.listen(PORT, "0.0.0.0");

A page served over https can’t open ws:// to a raw IP and port, and a Gameye session gives you exactly that. Pick one of these:

  • Per-session DNS and a wildcard certificate. When a session starts, your backend creates a record such as s-<session>.play.yourgame.example pointing at the session’s IP. The server terminates TLS with a wildcard certificate it fetches from your backend at boot. Never bake the key into the image or pass it in session env.
  • A WebSocket relay at the edge. The page connects over wss:// to a small relay, such as a Cloudflare Worker, that holds TLS and forwards to the session. Your backend signs a short-lived token naming the session’s address, and the relay connects only to that address.

Start node directly as the entrypoint. Under npm start, npm swallows SIGTERM and your drain code never runs. Build for linux/amd64.

FROM node:22-slim AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
FROM node:22-slim
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY server/ ./server/
USER node
EXPOSE 8080/tcp
ENTRYPOINT ["node", "server/server.js"]

When your matchmaker forms a match, your backend starts a session:

Terminal window
curl -X POST https://api.production-gameye.gameye.net/session \
-H "Authorization: Bearer $GAMEYE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "image": "your-image", "location": "eu-west-1", "env": { "MATCH_ID": "abc-123" } }'

The response holds the session’s host and the host port mapped to your container port. Send those to the players, or use them to create the DNS record or relay token from the TLS section. See Custom matchmaker for region selection and player tracking.

Scrapyard is a browser car-combat game whose authoritative server is Node.js and WebSocket. Nakama’s matchmaker forms each quick-play match, and the Gameye Fleet Manager for Nakama starts a Gameye session for it. Browsers reach the session through a Cloudflare Worker relay. GAMEYE.md in the repository walks through it, and scripts/gameye-up.sh runs it on your own Gameye account.

The gameye-skills web server skill and the gameye-mcp server can scaffold the Dockerfile, check it, and start sessions for you. See AI agents.