ServerVite Plugin

Vite Plugin

The Colyseus Vite plugin runs your game server inside Vite’s dev server using Vite’s Environment API. A single vite process serves your frontend and hosts the Colyseus server. Hot Module Replacement (HMR) covers both client components and server-side room definitions, with no process restart between edits.

It also drives the production build: vite build emits your static client and a standalone server bundle.

Requirements

  • A Vite project (vite >= 6.0.0).
  • The colyseus package: the plugin ships with it under the colyseus/vite subpath.

Setup

Add the colyseus() plugin to your Vite config, alongside any frontend plugins (such as @vitejs/plugin-react):

vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { colyseus } from 'colyseus/vite';
 
export default defineConfig({
  plugins: [
    react(),
    colyseus({
      serverEntry: '/src/server/index.ts',
    }),
  ],
});

The serverEntry file must export your server configuration as server (from defineServer()) or a rooms map. A default export of { server } / { rooms } also works.

src/server/index.ts
import { defineServer, defineRoom, createRouter, createEndpoint } from "colyseus";
import { MyRoom } from "./MyRoom.ts";
 
export const server = defineServer({
  rooms: {
    my_room: defineRoom(MyRoom),
  },
 
  // Type-safe HTTP routes, hot-swapped on reload
  routes: createRouter({
    hello: createEndpoint("/hello", { method: "GET" }, async () => {
      return { message: "Hello world!" };
    }),
  }),
 
  // Express middleware: set up once, persists across reloads
  express: (app) => {
    app.get('/express-hello', (_req, res) => {
      res.json({ message: 'Hello from Express!' });
    });
  },
});

Start everything with the usual Vite command:

npm run dev

Editing MyRoom.ts reloads the server in place; editing your frontend reloads the browser, both without restarting the process. The Colyseus server attaches to Vite’s own HTTP server, so your client and server share a single origin and port (no dev proxy required).

Options

OptionTypeDefaultDescription
serverEntrystring(required)Path to the module exporting server (or rooms).
portnumber2567Port the production server listens on. Dev mode reuses Vite’s port.
serveClientbooleanfalseIn the production build, serve the built client via express.static() with an SPA fallback to index.html. No effect in dev.
quietbooleanfalseSuppress the plugin’s reload/room logs.
httpServerhttp.Server—Required only when running Vite in middleware mode.
loadWsTransport() => Promise<…>—Provide a custom WebSocket transport loader. Defaults to @colyseus/ws-transport.

How server HMR works

The plugin enables Colyseus Development Mode internally. When you save a server file:

  1. The server module is re-imported, picking up your new code.
  2. HTTP routes and room definitions are swapped for the new ones.
  3. Active rooms are cached, disposed, and restored: state and seat reservations are preserved, and connected clients reconnect automatically.

Because this reuses the dev-mode machinery, the same hooks and caveats apply:

  • Implement onCacheRoom() / onRestoreRoom() to preserve data held outside the room state.
  • On the frontend, the onAdd schema callbacks fire again after a reload. Be ready to ignore duplicate calls during development.
⚠️

Like devMode, this is for local development only. The production build (below) runs a normal Colyseus server with HMR disabled.

Production build

vite build --app

This produces:

  • dist/client/: your static frontend assets.
  • dist/server/server.mjs: a standalone server that imports your entry and calls server.listen().

Run it with Node:

node dist/server/server.mjs

To have the server also serve the built client (single deployable), enable serveClient:

vite.config.ts
colyseus({
  serverEntry: '/src/server/index.ts',
  port: 2567,
  serveClient: true,
})

With serveClient, the production server mounts dist/client/ via express.static() and adds an SPA fallback that returns index.html for unmatched GET requests.

Middleware mode

In standalone dev mode the plugin attaches the WebSocket transport to Vite’s own HTTP server. When you run Vite in middleware mode (embedding it inside your own Express/HTTP server), that server owns the socket. You must pass it explicitly:

colyseus({
  serverEntry: '/src/server/index.ts',
  httpServer: myHttpServer,
})

Using Nitro

Frameworks such as TanStack Start deploy through Nitro’s Vite plugin. The Colyseus plugin runs next to it, in development and in production. This setup needs colyseus 0.18.6 or later.

Earlier versions fail to load the Colyseus server in development, with TypeError: Cannot read properties of undefined (reading 'import'). Their production build also omits dist/server/server.mjs.

⚠️

Colyseus with Nitro may not work yet on every production target that Nitro supports. The examples below were tested with Nitro’s Node.js presets, node-server and node-middleware. If you deploy to another target, feedback is welcome via GitHub Issues.

Plugin order

List colyseus() before nitro(). Nitro’s dev server handles every request that reaches it, including the matchmaker routes. If Nitro comes first, those requests return a 404 from your app, and joining a room fails. Your rooms still show as defined in the terminal, so check the plugin order first.

vite.config.ts
import { defineConfig } from 'vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import { nitro } from 'nitro/vite';
import viteReact from '@vitejs/plugin-react';
import { colyseus } from 'colyseus/vite';
 
export default defineConfig({
  plugins: [
    colyseus({ serverEntry: '/src/server/index.ts' }),
    tanstackStart(),
    nitro(),
    viteReact(),
  ],
});

In development, your pages, the matchmaker and room connections share Vite’s port.

Deploying as two processes

vite build writes two outputs: the Nitro app in .output/, and the Colyseus server in dist/server/server.mjs. Run each one as its own process:

node .output/server/index.mjs   # Nitro, port 3000
node dist/server/server.mjs     # Colyseus, port 2567

The two processes listen on different ports. Point your frontend’s Client at the Colyseus port, which differs from the port it uses in development.

Deploying as one process

To serve everything from the Colyseus server, build Nitro as a Node middleware instead of a standalone server:

vite.config.ts
nitro({ preset: 'node-middleware', serveStatic: true }),

The node-middleware preset makes .output/server/index.mjs export a middleware function instead of starting a server. The serveStatic option makes that middleware serve your frontend’s built assets too. Without it, pages render but their scripts return 404.

Mount the middleware from the express option of your server entry:

src/server/index.ts
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { defineServer, defineRoom, isDevMode } from "colyseus";
import { MyRoom } from "./MyRoom.ts";
 
export const server = defineServer({
  rooms: {
    my_room: defineRoom(MyRoom),
  },
 
  express: async (app) => {
    // In development, Vite serves the Nitro app itself.
    if (isDevMode) { return; }
 
    const nitroEntry = pathToFileURL(resolve(".output/server/index.mjs")).href;
    const { middleware } = await import(/* @vite-ignore */ nitroEntry);
    app.use(middleware);
  },
});

Colyseus handles its own HTTP routes and WebSocket connections first. Every other request reaches Nitro.

Start the server from your project root. The Nitro path resolves against the working directory, so the server fails to start from anywhere else:

node dist/server/server.mjs
  • Leave serveClient off. Nitro serves your frontend, so dist/client/ is never built. With serveClient on, every page returns a 404.
  • Keep the isDevMode check. The plugin also calls express in development, before .output/ exists. Without the check, development logs a misleading Express not available warning.