This is a minimal Colyseus 0.17 app running on Vercel's WebSocket support (public beta) — realtime multiplayer + Express HTTP routes on one server. Move a dot, chat, sync state across tabs, and hit plain HTTP endpoints.
- Server:
colyseus@0.17+@colyseus/ws-transport+express - Client:
@colyseus/sdk@0.17(from a CDN inpublic/index.html) - Host: Vercel Functions on Fluid compute
👉 Live: https://colyseus-vercel.vercel.app
Note: requires
@colyseus/core@^0.17.44, which addsServer.serverless()(see Whyserverless()).
Vercel exposes two ways to run a Node HTTP server, and they behave differently:
| pattern | what Vercel drives | WebSockets | Express routes |
|---|---|---|---|
server.listen() ("captured server") |
the server's request listeners | ✅ | ❌ (Express app handler not invoked) |
export default server |
the server's full request handling | ✅ | ✅ |
Colyseus normally calls gameServer.listen(), which lands on the captured
server path — great for realtime, but Vercel doesn't invoke Express there. To
get realtime and Express, this demo uses the export default server
path via a new Server.serverless():
// src/server.ts
import { createServer } from "node:http";
import { Server } from "@colyseus/core";
import { WebSocketTransport } from "@colyseus/ws-transport";
import { MyRoom } from "./rooms/MyRoom";
const httpServer = createServer();
const gameServer = new Server({
transport: new WebSocketTransport({ server: httpServer }),
express: (app) => {
app.get("/hello", (_req, res) => res.json({ hello: true }));
app.get("/api/health", (_req, res) => res.json({ status: "ok" }));
},
});
gameServer.define("my_room", MyRoom);
// set up matchmaking + routes WITHOUT binding a port; export for Vercel
const server = await gameServer.serverless();
// local dev only — serverless() doesn't listen (Vercel drives the export)
if (!process.env.VERCEL) server.listen(Number(process.env.PORT) || 2567);
export default server;Two more requirements for the export default path on Vercel:
package.json"main": "src/server.ts"— Vercel's auto-detection doesn't recognize a default-exported server wired through library code, so point it at the entrypoint explicitly.- Create the
http.Serverin your file (createServer()) and pass it to the transport, so the default export is a server Vercel can consume.
vercel.json only adds a maxDuration bump and a / → /index.html redirect
for the landing page.
If you don't need Express routes, still use serverless() — just omit the
express option. On Vercel a bare gameServer.listen(port) is not captured
(Vercel drives a default-exported server, not a listening one), so
serverless() + export default is the path either way.
Server.serverless() (added to @colyseus/core) does what listen() does —
matchMaker.accept() + bind the matchmaking/HTTP routes — but without binding
a port, and returns the underlying http.Server. It also pre-reads request
bodies into req.body so matchmaking POSTs work under the export default
path (Vercel doesn't drain the router's lazy request stream there).
npm install
npm run dev # Colyseus on http://localhost:2567
npx serve public # serve the client; it connects to ws://localhost:2567Headless smoke test (join a room, exercise state sync + chat):
node test-client.mjs # local
ENDPOINT="wss://your-app.vercel.app" node test-client.mjs # deployed-
Single-instance only. A room lives in one function instance's memory, and after matchmaking the client's WebSocket must reach the instance that owns the room (
matchMaker.getLocalRoomById). Vercel can't sticky-route a WS to a specific instance by room, so this holds only while traffic stays on one warm Fluid instance. Demo-grade; real horizontal scale needs routing Vercel doesn't expose. -
WebSocket connections close at the function's
maxDuration— per Vercel's docs: "WebSocket connections close when a Vercel Function reaches its maximum duration." Set to300shere (Hobby's default/max; Pro/Enterprise allow up to 800s, 1800s extended). The client auto-reconnects viaonDrop/onReconnectwhen this happens. -
The landing page is served via a redirect.
vercel.jsonredirects/→/index.html(a static file) so the bare domain serves the client page rather than Colyseus's root response. -
Per-deployment
*.vercel.appURLs return 401 (Deployment Protection). Use the production alias to test, or disable protection in project settings.
