# Colyseus documentation Full text of every page at https://docs.colyseus.io. Individual pages are available as markdown by appending `.md` to any documentation URL. # Colyseus Multiplayer Framework Colyseus is an open-source Node.js framework for building authoritative game servers, with real-time state synchronization, matchmaking, and simple integration into any game engine or frontend. Authentication, database services, an admin panel, and dev tooling are all included. ## Key Features - [Rooms & Matchmaking](https://docs.colyseus.io/room.md): From a single Room definition, clients are matched into multiple room instances, with visibility flags, lobbies, and ranked queues. - [State Synchronization](https://docs.colyseus.io/state.md): Define your state on the server; clients receive binary delta patches automatically. - [Prediction-Ready Netcode](https://docs.colyseus.io/netcode.md): Built-in client prediction, rollback, interpolation, and lag compensation, new in 0.18. - [Full-Stack Type Safety](https://docs.colyseus.io/sdk.md): Share your server's types with the SDK: typed messages, typed join options, typed state. - [Any Engine](https://docs.colyseus.io/getting-started.md): Official SDKs for the Web, Unity, Godot, Defold, GameMaker, Construct, Haxe, and more. - [Scale & Deploy Anywhere](https://docs.colyseus.io/scalability.md): Horizontal scaling across processes. Self-host for free, or use Colyseus Cloud for managed hosting. ## What does it look like? ### Create a server Create and start a new Colyseus server using the following commands: **Node.js** ```sh filename="Terminal: Node.js" # Create a new Colyseus project npm create colyseus-app@latest ./my-server # Enter the project directory cd my-server # Run the server npm start ``` **Bun** ```sh filename="Terminal: Bun" # Create a new Colyseus project bun create colyseus-app@latest ./my-server # Enter the project directory cd my-server # Install Bun transport & Run the server bun add @colyseus/bun-websockets bun run src/index.ts ``` ### Define your Room State The Room state is the data structure that will be synchronized between the server and the connected clients: **Schema Builder** ```ts filename="src/rooms/MyState.ts" import { schema, t } from "@colyseus/schema"; export const Player = schema({ x: t.number().default(0), y: t.number().default(0), }, "Player"); export const MyState = schema({ players: t.map(Player), }, "MyState"); ``` **Decorators** ```ts filename="src/rooms/MyState.ts" import { Schema, MapSchema, type } from "@colyseus/schema"; export class Player extends Schema { @type("number") x: number = 0; @type("number") y: number = 0; } export class MyState extends Schema { @type({ map: Player }) players = new MapSchema(); } ``` ### Create your Room code Define the game logic and client interactions with the game state within your Room code. ```ts filename="src/rooms/MyRoom.ts" import { Room, Client } from "colyseus"; import { MyState, Player } from "./MyState"; export class MyRoom extends Room { maxClients = 4; state = new MyState(); messages = { doSomething: (client, payload) => { // handle client's "doSomething" message } } // Called when the room is created onCreate(options) { } // Called when a client joins the room onJoin(client: Client, options: any) { this.state.players.set(client.sessionId, new Player()); } // Called when a client leaves the room onLeave(client: Client, code: number) { this.state.players.delete(client.sessionId); } // Called when the room is disposed onDispose() { } } ``` ### Expose the Room identifier Exposing the room type allows clients to connect to the server and dynamically create instances of it. ```ts {6} filename="src/app.config.ts" import { defineServer, defineRoom } from "colyseus"; import { MyRoom } from "./MyRoom"; const server = defineServer({ rooms: { my_room: defineRoom(MyRoom), }, }); ``` ### Join the Room from the client SDK Communication between the client and server occurs through room connections. Below are examples for each SDK showing how to join a room and listen for state changes: **TypeScript** ```ts filename="client.ts" import { Client, Callbacks } from "@colyseus/sdk"; async function connect() { const client = new Client('http://localhost:2567'); const room = await client.joinOrCreate('my_room', { /* custom join options */ }); const callbacks = Callbacks.get(room); // Listen to 'player' instance additions callbacks.onAdd("players", (player, sessionId) => { console.log('Player joined:', player); }); // Listen to 'player' instance removals callbacks.onRemove("players", (player, sessionId) => { console.log('Player left:', player); }); // Send messages to the server room.send("doSomething", {/* ... */}); return room; } connect(); ``` **JavaScript** ```js filename="client.js" import * as Colyseus from "@colyseus/sdk"; const client = new Colyseus.Client('http://localhost:2567'); client.joinOrCreate('my_room', { /* custom join options */ }).then(function(room) { const callbacks = Colyseus.Callbacks.get(room); // Listen to 'player' instance additions callbacks.onAdd("players", (player, sessionId) => { console.log('Player joined:', player); }); // Listen to 'player' instance removals callbacks.onRemove("players", (player, sessionId) => { console.log('Player left:', player); }); // Send messages to the server room.send("doSomething", {/* ... */}); }); ``` **C#** ```cs filename="Client.cs" using Colyseus; async void Connect() { var client = new Client("http://localhost:2567"); try { var room = await client.JoinOrCreate("my_room"); var callbacks = Colyseus.Schema.Callbacks.Get(room); // Listen to 'player' instance additions callbacks.OnAdd(state => state.players, (sessionId, player) => { Debug.Log($"Player joined: {sessionId}"); }); // Listen to 'player' instance removals callbacks.OnRemove(state => state.players, (sessionId, player) => { Debug.Log($"Player left: {sessionId}"); }); // Send messages to the server room.Send("doSomething", new { /* ... */ }); } catch (Exception ex) { Debug.Log("join error"); Debug.Log(ex.Message); } } ``` **Lua** ```lua filename="client.lua" local ColyseusSDK = require("colyseus.sdk") local client = ColyseusSDK.Client("http://localhost:2567") client:join_or_create("my_room", {--[[custom join options]]}, function(err, room) if (err ~= nil) then print("join error: " .. err) return end local callbacks = ColyseusSDK.callbacks(room) -- Listen to 'player' instance additions callbacks:on_add("players", function(player, sessionId) print("Player joined:", sessionId) end) -- Listen to 'player' instance removals callbacks:on_remove("players", function(player, sessionId) print("Player left:", sessionId) end) -- Send messages to the server room:send("doSomething", {--[[ ... ]]}) end) ``` **Haxe** ```haxe filename="Client.hx" import io.colyseus.Client; import io.colyseus.serializer.schema.Callbacks; class Main { static function main() { var client = new Client("http://localhost:2567"); client.joinOrCreate("my_room", [], MyRoomState, function(err, room) { if (err != null) { trace("join error: " + err); return; } var callbacks = Callbacks.get(room); // Listen to 'player' instance additions callbacks.onAdd("players", function(player, sessionId) { trace("Player joined: " + sessionId); }); // Listen to 'player' instance removals callbacks.onRemove("players", function(player, sessionId) { trace("Player left: " + sessionId); }); // Send messages to the server room.send("doSomething", [/* ... */]); }); } } ``` ### Explore more Explore more about Colyseus by following the tutorials and example projects: - [Example Projects](https://docs.colyseus.io/learn/examples.md) - [Tutorials](https://docs.colyseus.io/learn/tutorial.md) --- Source: https://docs.colyseus.io/ # 3rd Party Packages > **Note:** > > **Contribute** - Click on the "Edit this page" link at the sidebar to add more packages. This page contains a curated list of third-party npm modules that can help you with specific tasks on the backend, frontend, or both. ## Random Number Generator When building procedurally generated content, use a predictable random number generator. This way, you can reproduce the same content across clients without having to synchronize the generated output. - [ seedrandom ↗](https://www.npmjs.com/package/seedrandom) ## Entity-Component System (ECS) An Entity-Component System is a design pattern used mostly in game development. This pattern can help you build complex systems in a more modular way. - [ miniplex ↗](https://www.npmjs.com/package/miniplex) ## Spatial Partitioning Spatial partitioning is a technique used in game development to optimize collision detection and other spatial queries. Use a quadtree for 2D queries. For triangle meshes, a bounding volume hierarchy (BVH) accelerates raycasts and shape queries against level geometry. `three-mesh-bvh` requires no DOM or WebGL, so the same collision code runs on the backend and in the browser. - [ @timohausmann/quadtree-ts ↗](https://www.npmjs.com/package/@timohausmann/quadtree-ts) - [ three-mesh-bvh ↗](https://www.npmjs.com/package/three-mesh-bvh) ## Physics Avoid running an entire physics engine on the backend. You can use a physics engine to calculate collisions and other physics-related computations. ### 3D Physics - [ @perplexdotgg/bounce ↗](https://www.npmjs.com/package/@perplexdotgg/bounce) - [ crashcat ↗](http://npmjs.com/package/crashcat) ### 2D Physics - [ Planck.js ↗](https://www.npmjs.com/package/planck) - [ matter.js ↗](https://www.npmjs.com/package/matter-js) - [ kinetics.ts ↗](https://www.npmjs.com/package/kinetics.ts) - [ grid-engine ↗](https://www.npmjs.com/package/grid-engine) ### Navigation Mesh - [ navcat ↗](https://www.npmjs.com/package/navcat) ## Loading 3D Models in Node An authoritative server often needs the same geometry the client renders, for collision or raycasts. The `GLTFLoader` from three.js expects browser APIs, so it does not run under Node. - [ node-three-gltf ↗](https://www.npmjs.com/package/node-three-gltf) Convert the model at build time instead of at server boot. Read the `.glb` once, write the triangles you need into a compact binary, then load that file from both the backend and the frontend. ## Profanity Filter When allowing user-generated content, you may want to filter out profanity from the messages. - [ obscenity ↗](https://npmjs.com/package/obscenity) --- Source: https://docs.colyseus.io/3rd-party-packages # Admin Panel `@colyseus/admin` is a browser-based operations console for projects using [`@colyseus/database`](https://docs.colyseus.io/database.md). The panel provides user management (browse, ban, revoke sessions), CRUD over your drizzle tables, a live room inspector, and a customizable dashboard. Everything is gated by session-based login with role-based access control and an append-only audit log. ![The admin dashboard: totals, recent users and live rooms widgets](https://docs.colyseus.io/admin/dashboard.webp) For a lightweight, no-auth dashboard intended for room and process inspection, see the [Monitoring Panel](https://docs.colyseus.io/tools/monitoring.md) instead. The admin panel is the heavier counterpart, built for production operations. > **Warning:** > > `@colyseus/admin` is in **beta**: APIs may change between releases as we incorporate community feedback. Share yours on the [public roadmap](https://docs.colyseus.io/roadmap.md). ## Installation On top of `@colyseus/core`, the panel has two peer dependencies: [`@colyseus/database`](https://docs.colyseus.io/database.md) (user store, audit log, drizzle table introspection) and [`@colyseus/auth`](https://docs.colyseus.io/auth/module.md) (JWT signing, password hashing). Install all three together: ```sh npm2yarn npm install --save @colyseus/admin @colyseus/database @colyseus/auth ``` ## Mounting The admin panel mounts in two ways: **Standard router (recommended)** Spread `admin({})` into `createRouter`. The factory returns both the SPA static-serving routes and the REST API in one map: ```ts {3,10} filename="src/app.config.ts" import { defineServer, createRouter } from "colyseus"; import { GameDatabase } from "@colyseus/database"; import { admin } from "@colyseus/admin"; export const db = new GameDatabase({ connectionString: process.env.DATABASE_URL }); export default defineServer({ database: db, routes: createRouter({ ...admin({}), }), }); ``` **Express middleware** For Express-based stacks, `admin(...)` is also a valid Express middleware. Mount it at the root to keep the default `/admin` and `/admin-api` paths, or under any path to move both: ```ts {3,10,13} filename="src/app.config.ts" import { defineServer } from "colyseus"; import { GameDatabase } from "@colyseus/database"; import { admin } from "@colyseus/admin"; export const db = new GameDatabase({ connectionString: process.env.DATABASE_URL }); export default defineServer({ database: db, express: (app) => { app.use(admin({})); // UI at /admin/, API at /admin-api // or, under a path: UI at /ops/, API at /ops/admin-api // app.use("/ops", admin({})); }, }); ``` Behind a reverse proxy that strips a prefix, forward it as `X-Forwarded-Prefix` and the served HTML adjusts its base URL. > **Note:** > > The `database` option defaults to `GameDatabase.current`, set automatically when you construct `new GameDatabase(...)`. Multi-database deployments should pass `database` explicitly (e.g. `admin({ database: db })`) to disambiguate which instance the panel drives. `admin()` throws at startup when neither is available. ## Usage Once mounted, start your server and open `http://localhost:2567/admin/` in your browser (the bare `/admin` redirects to the trailing slash). If you customized `uiPath`, adjust the URL accordingly. The REST API mounts in parallel at `http://localhost:2567/admin-api` and is consumed by the SPA. Every endpoint enforces the same session and role rules as the UI, so you can script the panel with `curl` or `fetch` using the session cookie. The sub-pages list the endpoints for each area. `GET /admin-api/_health` is the one unauthenticated route: it answers `200 { ok, db, latencyMs }` or `503` when the database is unreachable, for readiness probes. ## First-run bootstrap The admin panel includes **no built-in admin user**. The SPA calls `GET /admin-api/auth/status` on load; while no user has the `admin` role it reports `needsBootstrap: true` and the panel renders a one-time setup form. ![The first-run setup form asking for an email and password](https://docs.colyseus.io/admin/bootstrap.webp) Submitting the form calls `POST /admin-api/auth/bootstrap` with `{ email, password }`. If a user with that email already exists (for example, you registered through your game first), that account is promoted instead of created. After bootstrap: - A session cookie is set (HttpOnly, SameSite=Strict, JWT-signed). - You're redirected into the panel with full `admin` role access. - All sibling routes gated with `admin.guard()` (e.g. the Playground or Monitor) now resolve against the same session. - The endpoint refuses further calls with `403` as soon as one admin exists. > **Note:** > > See [Authentication & RBAC](https://docs.colyseus.io/admin/authentication.md) for the full session, role, and revocation story. ## Hardening for production The defaults are tuned for local development. Before exposing the panel to the public internet: - **Set a strong `JWT_SECRET` and `SESSION_SECRET`**: see [Auth Module → Required Environment Secrets](https://docs.colyseus.io/auth/module.md#required-environment-secrets). With `NODE_ENV=production`, `admin()` throws at startup when no JWT secret is configured; outside production it only warns. Rotating `JWT_SECRET` invalidates all admin sessions (`SESSION_SECRET` only signs OAuth state cookies). - **Keep player accounts out of the panel.** `POST /admin-api/auth/login` accepts any user row with a password. The `user` role then passes the list/read gate on every resource and on the room inspector. A player with an email and password can therefore read live room state and other players' emails through the API. Until the package closes this, serve the panel on a separate hostname or behind your own network guard. Keeping admin accounts in a dedicated database also works. See [Authentication → Roles](https://docs.colyseus.io/admin/authentication.md#roles). - **Wire [`auth.settings.onForgotPassword`](https://docs.colyseus.io/auth/module.md#on-forgot-password)** so password reset links reach your admins. The panel ships no reset page yet; see [Password reset](https://docs.colyseus.io/admin/authentication.md#password-reset) for the API flow. - **Tighten the session cookie** via the `session` option: set an explicit `cookieDomain`, an appropriate `ttlSeconds`, and confirm the `cookieSecure`/`cookieSameSite` values match your deployment. - **Disable `allowDevHeader`** if you don't need it. It auto-disables when `NODE_ENV === "production"`, but setting it to `false` explicitly avoids surprises in mixed environments. - **Swap the in-memory rate limiters for a Redis-backed implementation** on multi-process deployments. The default token buckets don't share state across instances. The limiter keys on `X-Forwarded-For` / `X-Real-IP` when present, so only expose the panel behind a proxy that overwrites those headers. - **Gate the Monitor and Playground** behind `admin.guard()` so you don't have multiple unauthenticated panels exposed: ```ts filename="src/app.config.ts" routes: createRouter({ ...playground({ use: [admin.guard()] }), ...monitor({ use: [admin.guard()] }), ...admin({}), }), ``` > **Important:** > > `enforceRbac: false` removes every identity check: any anonymous request can create, update, delete, kick, dispose and ban, and audit rows are written without an `operatorId`. Use it only on a local database. ## Options reference `admin(options)` accepts the following `AdminOptions`: | Option | Type | Default | Description | |---|---|---|---| | `database` | `GameDatabase` | `GameDatabase.current` | The database powering the user store, audit log, and table introspection. Pass explicitly for multi-database setups. | | `tables` | `Record` | `database.tables` | Map of drizzle tables exposed in the panel. Custom tables must be added here: `{ ...db.tables, guilds }`. See [Resources & CRUD](https://docs.colyseus.io/admin/resources.md#exposing-a-custom-table). | | `resources` | `Record` | built-in defaults | Per-table UI/UX overrides: labels, visible columns, form fields, actions, policies. The audit log and user notes get built-in definitions unless you override them. See [Resources & CRUD](https://docs.colyseus.io/admin/resources.md). | | `uiPath` | `string` | `"/admin"` | Mount path for the admin SPA. Ignored under an Express path mount, where the mount path wins. | | `apiPath` | `string` | `"/admin-api"` | Mount path for the admin REST API. Nested inside the mount under an Express path mount. | | `uiDistDir` | `string` | _built-in_ | Absolute path to the built UI assets. Defaults to the bundled `build/` directory inside the package. | | `session` | `SessionConfig` | _see below_ | Cookie config: `ttlSeconds`, `cookieDomain`, `cookieSameSite`, `cookieSecure`. | | `resolveUserId` | `(ctx: { getHeader }) => string \| undefined \| Promise<…>` | session cookie, then `X-User-Id` in dev | Replace the identity resolver to integrate another auth scheme. Your function receives only `getHeader(name)`. The default also rejects sessions whose `tokenVersion` was bumped; a replacement has to repeat that check. | | `enforceRbac` | `boolean` | `true` | Set to `false` to skip every identity and role check (local development only, see the warning above). | | `allowDevHeader` | `boolean` | `true` in dev, `false` in prod | Permit `X-User-Id: ` as a fallback identity for curl/puppeteer testing. | | `minPasswordLength` | `number` | `8` | Minimum password length accepted by the bootstrap and reset endpoints. Plain length check, no complexity rules. | | `onResetRequest` | `(ctx) => void \| Promise` | forwards to `auth.settings.onForgotPassword`, else logs the link | Deliver the password reset link. Receives `{ email, userId, token, url }`; `url` is a site-relative path. See [Password reset](https://docs.colyseus.io/admin/authentication.md#password-reset). | | `rateLimit` | `{ login, bootstrap, requestReset }` | in-memory token buckets | Per-endpoint rate limiters. Pass `false` per slot to disable, or a custom `RateLimiter` to swap in a Redis-backed implementation. | | `dashboard` | `{ presets, widgets }` | all presets enabled | Configure the dashboard homepage. See [Dashboard & widgets](https://docs.colyseus.io/admin/dashboard.md). | | `logger` | `Logger \| null` | JSON to stdout / stderr | Pino-compatible logger (`info`, `warn`, `error`, `child`). `warn` and `error` go to stderr; `debug` only prints with `LOG_DEBUG=1`. Pass `null` to silence the panel's internal logs. | ### Session defaults The `session` config controls the JWT cookie. Defaults: ```ts session: { ttlSeconds: 7 * 24 * 60 * 60, // 1 week cookieSameSite: "Strict", cookieSecure: process.env.NODE_ENV === "production", // cookieDomain: undefined ← host-only; set explicitly when serving from a subdomain } ``` The cookie is always `HttpOnly`, scoped to `Path=/`, and signed with `JWT_SECRET`. Revoking a session is done via `db.auth.bumpTokenVersion(userId)`. See [Database → Authentication](https://docs.colyseus.io/database/authentication.md). ## Next steps - [Authentication & RBAC](https://docs.colyseus.io/admin/authentication.md): sessions, roles, password reset, and `admin.guard()`. - [Resources & CRUD](https://docs.colyseus.io/admin/resources.md): exposing custom tables, customizing columns and forms, custom actions, the audit log. - [Dashboard & widgets](https://docs.colyseus.io/admin/dashboard.md): configuring presets and appending custom widgets to the homepage. - [Live rooms](https://docs.colyseus.io/admin/rooms.md): the built-in room inspector. --- Source: https://docs.colyseus.io/admin # Authentication & RBAC The admin panel uses **session-based JWT cookies** for authentication and a three-role hierarchy (`admin` / `mod` / `user`) for authorization. Both reuse [`@colyseus/database`](https://docs.colyseus.io/database.md) and [`@colyseus/auth`](https://docs.colyseus.io/auth/module.md), so revocation, password hashing, and JWT signing follow the same rules as your application's player auth. ## Sessions When an admin signs in (`POST /admin-api/auth/login`), the panel sets an HttpOnly cookie carrying a signed JWT with `userId`, `role`, `iat`, `exp`, and `tv` (token-version) claims. | Property | Default | Notes | |---|---|---| | Cookie name | `colyseus_admin_session` | Fixed, not configurable | | `HttpOnly` | `true` | Always, not configurable | | `Path` | `/` | Always | | `SameSite` | `Strict` | Configurable via `session.cookieSameSite` (`'Strict' \| 'Lax' \| 'None'`) | | `Secure` | `true` in production | Auto-set based on `NODE_ENV`; override via `session.cookieSecure` | | TTL | 7 days | Configurable via `session.ttlSeconds` | | Domain | host-only | Set via `session.cookieDomain` when serving from a subdomain | There is no server-side session store. Every request verifies the cookie's signature, then re-reads two columns from the database. The user's `tokenVersion` must equal the `tv` claim, and the role is always the current one. Two consequences: - **Logout** (`POST /admin-api/auth/logout`) clears the cookie. The JWT itself remains valid until it expires unless its `tokenVersion` is bumped. - **Revocation** happens by incrementing the user's `tokenVersion` column. `db.auth.bumpTokenVersion(userId)` invalidates every session issued before the call on its next verification. `db.auth.ban()` does the same atomically with the ban itself. The panel's **Revoke sessions** and **Ban** buttons bump the version. They also close the user's live WebSocket connections in every room they are in. `POST /admin-api/auth/logout-everywhere` does both for the signed-in admin: it bumps their `tokenVersion` and clears the cookie. Use it after a suspected cookie leak. See [Database → Authentication](https://docs.colyseus.io/database/authentication.md) for the full revocation story. ### Endpoints | Method | Path | Auth | Audit action | |---|---|---|---| | `GET` | `/admin-api/auth/status` | none | — | | `POST` | `/admin-api/auth/bootstrap` | none, refused once an admin exists | `auth.bootstrap` | | `POST` | `/admin-api/auth/login` | none | `auth.login`, `auth.login_failed` | | `GET` | `/admin-api/auth/me` | session | — | | `POST` | `/admin-api/auth/logout` | session | `auth.logout` | | `POST` | `/admin-api/auth/logout-everywhere` | session | `auth.logout` | | `POST` | `/admin-api/auth/request-reset` | none | `auth.password_reset_requested` | | `POST` | `/admin-api/auth/reset` | reset token | `auth.password_reset_completed` | `/auth/status` returns `{ needsBootstrap, authenticated, userId, role }` and drives the SPA's first-run and login redirects. Every auth event lands in the [audit log](https://docs.colyseus.io/admin/resources.md#audit-log) with `resource: "auth"`, including failed logins with the attempted email, IP and user agent. ## Roles Each admin-panel user has a row in the `roles` table (set via `db.moderation.setRole(userId, role)`). A user without a row counts as `user`. Three roles exist: | Role | Default access | |---|---| | `admin` | Full access: all resources, all actions, all rooms. | | `mod` | List, read, and update, only on resources assigned via `db.moderation.assignMod()`. No create or delete, no access outside assigned scopes, and the roles table is always blocked. | | `user` | Cannot open the panel UI (the catalog and dashboard answer 403: an operator role is required). Direct API calls to list and read endpoints still succeed. | Roles are read from the live DB on every request. A demotion takes effect immediately without re-issuing the cookie. > **Warning:** > > `POST /admin-api/auth/login` issues a session to any user row that has a password, whatever its role. Combined with the `user` row above, a registered player can call `GET /admin-api/users` or `GET /admin-api/rooms/:roomId`. Those answers contain other players' emails and live room state. Until the package closes this gap, keep the panel off the player-facing hostname. A network-level guard of your own in front of `/admin-api` also works. ### Promoting a user to admin From anywhere with access to the database instance (e.g. a one-off script or an admin-only endpoint): ```ts await db.moderation.setRole(userId, "admin"); ``` The first admin is created by the [bootstrap flow](https://docs.colyseus.io/admin.md#first-run-bootstrap) on first visit; subsequent admins are promoted from the panel's user-management UI. ### Scoping a moderator to specific resources Mods get mod-level access only on the resources they're assigned to. Assignments are per-resource and use the canonical table key (`guilds`, `userNotes`, `rooms`), not the SQL table name: ```ts await db.moderation.assignMod(userId, "guilds"); // mod on `guilds` await db.moderation.assignMod(userId, "leaderboards"); // also mod on leaderboards await db.moderation.unassignMod(userId, "guilds"); // revoke ``` `assignMod()` promotes a plain `user` to `mod` on the first assignment. `unassignMod()` never demotes: a mod with no scopes left keeps the role and can still open the panel, so call `setRole(userId, "user")` when you want them out. This pattern lets you give a community manager full control over guilds without exposing every other table. See [Resources & CRUD → Policies](https://docs.colyseus.io/admin/resources.md#policies) for how to declare per-action role gates on individual resources. ## `admin.guard()` The `admin.guard()` middleware checks the same session cookie used by the panel itself. You can therefore gate other HTTP routes (the Monitor, the Playground, or any custom endpoint) behind a single admin login. ```ts {1,6-7} filename="src/app.config.ts" import { admin } from "@colyseus/admin"; import { playground } from "@colyseus/playground"; import { monitor } from "@colyseus/monitor"; routes: createRouter({ ...playground({ use: [admin.guard()] }), ...monitor({ use: [admin.guard({ role: "mod" })] }), ...admin({ database: db }), }), ``` The same middleware works on your own endpoints: ```ts createEndpoint("/ops/reindex", { method: "POST", use: [admin.guard()] }, async (ctx) => { // only admins reach this point }); ``` ### Behavior The guard decides how to reject from the `Accept` header. A request counts as a browser navigation when `Accept` contains `text/html` (a bare `*/*` does not). | Request type | Missing, invalid or revoked session | Insufficient role | |---|---|---| | Browser navigation (`Accept: text/html`) | `302` → `/?next=` | `302` → login, **session cookie cleared** to break redirect loops | | XHR / fetch / curl | `401` JSON body | `403` JSON body naming both roles: `requires role 'admin' or higher (you have 'mod')` | `next` carries the original path and query string. The guard omits it when the path is not a plain absolute path, so an open redirect can't be smuggled in. The "cookie cleared" behavior on insufficient role is intentional. A lower-role user visiting `/monitor` (gated to `mod` or higher) still holds a valid session cookie. The login page would otherwise treat them as already authenticated and redirect them right back through the guard, creating a loop. Clearing the cookie forces a real re-authentication. ### Options | Option | Type | Default | Description | |---|---|---|---| | `database` | `GameDatabase` | `GameDatabase.current` | Database to validate the session against. `admin.guard()` throws when neither is available. | | `role` | `'admin' \| 'mod' \| 'user'` | `'admin'` | Minimum role required. Hierarchy: admin > mod > user. | | `loginUrl` | `string` | `'/admin'` | Where to send unauthenticated browser visits. Match `uiPath` if you customized it. | | `apiOnly` | `boolean` | `false` | Force JSON responses even for browser navigations. Set to `true` when wrapping non-HTML endpoints. | ## Password reset The reset flow is implemented on the API, but **the panel ships no reset page yet**. The login screen has no "Forgot password?" link and the SPA has no `/reset` route. You drive the two endpoints from your own page, or from a script when an admin is locked out. 1. `POST /admin-api/auth/request-reset` with `{ email }`. The endpoint answers `200` whether or not the email exists, so it can't be used to enumerate users. Only a missing email (`400`) and the rate limit (`429`) answer differently. 2. If the email matches a user, the panel signs a short-lived token (`15` minutes, not configurable, single-use) and calls your `onResetRequest({ email, userId, token, url })` callback. 3. `url` is the site-relative path `/reset?token=`. Nothing prepends your public origin, so the callback has to build the absolute link, and the page at that path is yours to provide. 4. The page (or your script) calls `POST /admin-api/auth/reset` with `{ token, password }`. The endpoint verifies the token, writes the new hash via `db.auth.setPasswordHash()`, then bumps `tokenVersion` so every existing session for that user is terminated. A second call with the same token fails with `400`, because the bump changed the `tv` the token was signed with. > **Note:** > > The default `onResetRequest` forwards through [`auth.settings.onForgotPassword`](https://docs.colyseus.io/auth/module.md#on-forgot-password) when configured. It renders `html/admin-reset-password-email.html` from your project when present, and the standard `reset-password-email.html` otherwise. Both use the `[LINK]` placeholder (see [custom templates](https://docs.colyseus.io/auth/module.md#customizing-the-email-templates)). Without that hook it logs the link through the admin logger, which is fine for local development only. ```ts filename="src/app.config.ts" admin({ database: db, onResetRequest: async ({ email, url }) => { await mailer.send({ to: email, subject: "Reset your admin password", html: `

Click here to reset your password. The link expires in 15 minutes.

`, }); }, }), ``` Until you host a reset page, an operator can complete the flow from a terminal: ```sh curl -X POST http://localhost:2567/admin-api/auth/request-reset \ -H 'content-type: application/json' -d '{"email":"ops@example.com"}' # read the token from the logged link (or the email), then: curl -X POST http://localhost:2567/admin-api/auth/reset \ -H 'content-type: application/json' -d '{"token":"","password":"new-password"}' ``` ## Rate limits The auth endpoints are rate-limited to slow down brute-force attacks. Defaults are in-memory token buckets per limiter, sufficient for single-process deployments. Replace with a Redis-backed `RateLimiter` for multi-node setups so the buckets are shared across instances. | Endpoint | Default limit | Option | |---|---|---| | `/auth/login` | burst of 10, refill 10/min, per IP + email | `rateLimit.login` | | `/auth/bootstrap` | burst of 5, refill 1/min, per IP | `rateLimit.bootstrap` | | `/auth/request-reset` | burst of 3, refill 1/min, per IP + email | `rateLimit.requestReset` | A blocked request receives `429` with a `Retry-After` header. A limiter is any object with one method; `null` lets the request through, a `Response` blocks it: ```ts interface RateLimiter { check(key: string): Response | null | Promise; } ``` Each slot accepts `false` to disable that limiter (e.g. behind your own WAF). It also accepts a tighter in-memory bucket from the exported `createTokenBucketLimiter()`, or your own implementation: ```ts import { admin, createTokenBucketLimiter } from "@colyseus/admin"; admin({ database: db, rateLimit: { login: createTokenBucketLimiter({ capacity: 5, refillPerSec: 1 / 30, retryAfterSec: 30 }), requestReset: myRedisLimiter, // your own RateLimiter implementation bootstrap: false, }, }), ``` The bucket keys use the client IP as seen in `X-Forwarded-For` or `X-Real-IP` when present (`ipFromHeaders()` is exported if your limiter needs the same value). The in-memory map keeps one bucket per distinct key and is never pruned. A long-lived single process exposed to the open internet should prefer the Redis-backed variant. ## Next steps - [Resources & CRUD](https://docs.colyseus.io/admin/resources.md): per-resource policies (the third layer of access control, on top of role + scope). - [Hardening for production](https://docs.colyseus.io/admin.md#hardening-for-production): cookie domain, secrets rotation, and email delivery. --- Source: https://docs.colyseus.io/admin/authentication # Dashboard & widgets The admin panel homepage hosts a grid of **widgets**, small cards that surface live data from your database or runtime. Five **preset widgets** are enabled by default, and you can append **custom widgets** for anything else you want at a glance. ![The dashboard with the Totals, Recent users and Live rooms presets and a custom "Active events" KPI card](https://docs.colyseus.io/admin/dashboard.webp) ```ts {4-16} filename="src/app.config.ts" routes: createRouter({ ...admin({ database: db, dashboard: { presets: { totals: { only: ["users", "cloudSaves"] }, recentUsers: { limit: 10 }, health: false, // disable }, widgets: [{ title: "Active events", icon: "thunderbolt", render: "kpi", data: async () => ({ live: 12, queued: 3 }), }], }, }), }), ``` The dashboard is served by `GET /admin-api/_dashboard`, which returns `{ widgets: [...] }` with one entry per widget. Both this route and the per-widget refresh route are operator-only. Any admin or mod can load them (mods regardless of scope); a plain `user` gets `403`. ## Preset widgets | Preset | Default title | Render | Surfaces | |---|---|---|---| | `totals` | Totals | KPI | Row counts per table in `database.tables`, a useful "DB is healthy" snapshot. | | `recentUsers` | Recent users | Table | Newest user registrations with deep-link to each user's show page. | | `liveRooms` | Live rooms | Table | Currently-active rooms from the matchmaker driver with deep-link to the room inspector. Refreshes every 3 seconds. | | `health` | Database health | KPI | Database connectivity check. Reports `status` and query `latency`. | | `segments` | Segments | KPI | One count per cohort registered with `db.segments.define()` (see [Database](https://docs.colyseus.io/database.md)). Empty when none are defined. | All presets are enabled by default. Configure each via `dashboard.presets.`: | Value | Effect | |---|---| | _omitted_ or `true` | Enabled with defaults. | | `false` | Disabled: the preset is not rendered. | | Options object | Enabled with customization. | ### Preset options Every preset shares three base options (`title`, `icon`, `span`) and adds preset-specific knobs. #### `totals` ```ts totals: { title?: string; icon?: AdminIconName; span?: number; // 1-24, see Widget shape only?: string[]; // restrict to a subset of table keys } ``` ```ts totals: { only: ["users", "cloudSaves", "leaderboardEntries"] } ``` `totals` only knows the tables in `database.tables` (the built-ins). Custom tables passed through `admin({ tables })` and unknown names are dropped silently. Count a custom table with a [KPI widget](#kpi) instead. #### `recentUsers` ```ts recentUsers: { title?: string; icon?: AdminIconName; span?: number; limit?: number; // default 5 columns?: UserColumnName[]; // subset of users columns, in order } ``` ```ts recentUsers: { limit: 10, columns: ["id", "email", "createdAt"] } ``` `columns` uses the drizzle property names of your `users` table (`createdAt`, plus any column you added when extending the schema). Without `columns` the widget returns entire user rows to the browser, including `passwordHash`. Set `columns` on any panel that is not strictly local. #### `liveRooms` ```ts liveRooms: { title?: string; icon?: AdminIconName; span?: number; limit?: number; // default 10 columns?: LiveRoomColumnName[]; // default: roomId, name, clients, maxClients, locked, createdAt } ``` ```ts liveRooms: { limit: 20, columns: ["roomId", "name", "clients", "processId"] } ``` Available columns: `roomId`, `name`, `clients`, `maxClients`, `locked`, `private`, `createdAt`, `elapsedTime`, `processId`, `publicAddress`. `maxClients` renders as `∞` for uncapped rooms and `elapsedTime` is raw milliseconds. The 3-second refresh is built into the preset and is not configurable. #### `health` / `segments` Both accept only the base options (`title`, `icon`, `span`). No preset-specific knobs. ## Custom widgets Append your own widgets via `dashboard.widgets`. The widget list is resolved once when `admin()` runs; each widget's `data` function runs on every dashboard request and its result is sent to the client: ```ts widgets: [{ title: "Active events", // also the basis for the auto-derived id icon: "thunderbolt", // AdminIconName render: "kpi", // 'kpi' | 'table' | 'list' | 'json' (default 'json') span: 12, // 1-24 grid span (half width) refreshIntervalMs: 5000, // poll for live updates data: async ({ database, userId }) => ({ live: 12, queued: 3 }), }] ``` ### Widget shape ```ts interface DashboardWidget { id?: string; // defaults to slugify(title) title: string; icon?: AdminIconName; render?: WidgetRender; // 'kpi' | 'table' | 'list' | 'json' (default 'json') span?: number; // 1-24; default per render type refreshIntervalMs?: number; data: (ctx: { database: GameDatabase; userId: string }) => Promise; } ``` | Field | Notes | |---|---| | `id` | Stable identifier, also used for `data-testid="widget-"`. Defaults to `slugify(title)` (e.g. `"Live events"` → `"live-events"`). `admin()` throws at startup when the id collides with a preset or another widget, or when the title slugifies to an empty string. | | `data` | Server-side resolver. Receives the resolved `GameDatabase` and the requesting admin's `userId`. Errors are caught and surfaced to the panel as `{ error }` on that card only. | | `refreshIntervalMs` | When set, the client polls `GET /admin-api/_dashboard/` at this cadence and refreshes the card in place (no full dashboard reload). | | `span` | Width on a 24-unit scale, mapped onto the panel's grid. Defaults: `kpi`/`table`/`json` full width (24), `list` half (12). The grid collapses to a single column on mobile widths regardless of span. | ### Render modes The `render` field tells the panel how to interpret `data`'s return value. #### `kpi` `data` returns `Record`. The panel renders each entry as a label/value card, with the key as the label. ```ts { title: "Player counts", render: "kpi", data: async ({ database }) => { const [total, banned] = await Promise.all([ database.drizzle.$count(database.tables.users), database.drizzle.$count(database.tables.users, isNotNull(database.tables.users.bannedUntil)), ]); return { total, banned }; }, } ``` #### `table` `data` returns `TableWidgetData`: ```ts interface TableWidgetData { columns: string[]; // header order; auto-humanized for display rows: Array>; linkTo?: { resource: string; // admin resource key to link to idColumn?: string; // row column with the FK value; default 'id' }; } ``` When `linkTo` is set, the panel makes each row clickable and navigates to that resource's show page (`rooms` links into the [room inspector](https://docs.colyseus.io/admin/rooms.md)). This behavior is useful for "recent X" widgets that should deep-link back to the records. ```ts { title: "Top guilds", render: "table", data: async ({ database }) => ({ columns: ["id", "name", "memberCount"], rows: await database.drizzle.select().from(guilds).orderBy(desc(guilds.memberCount)).limit(10), linkTo: { resource: "guilds" }, }), } ``` #### `list` `data` returns `Array<{ title: string; description?: string }>`. The panel renders a simple labeled list, good for status feeds or named items without tabular structure. ```ts { title: "Recent events", render: "list", data: async () => [ { title: "Server deployed", description: "v1.4.2, 12 minutes ago" }, { title: "Hotfix applied", description: "v1.4.1, 2 hours ago" }, ], } ``` #### `json` `data` returns anything. The panel pretty-prints it in a scrollable code block. Default render mode when `render` is omitted (useful for ad-hoc diagnostics). ```ts { title: "Cache stats", render: "json", data: async () => cache.stats(), } ``` ## Refresh and polling By default, widgets resolve once when the dashboard loads. For live data, set `refreshIntervalMs` to enable client-side polling of `GET /admin-api/_dashboard/`. Only the affected card re-renders, which is useful for a live rooms count or active sessions card without paying for a full dashboard refresh. The first refresh fires one interval after the page loads; a failed poll keeps the last good value. ```ts { title: "Signups today", render: "kpi", refreshIntervalMs: 3000, // every 3s data: async ({ database }) => { const since = new Date(Date.now() - 24 * 60 * 60 * 1000); return { signups: await database.drizzle.$count(database.tables.users, gte(database.tables.users.createdAt, since)), }; }, } ``` ## Next steps - [Resources & CRUD](https://docs.colyseus.io/admin/resources.md): for the underlying tables that widgets pull from. - [Live rooms](https://docs.colyseus.io/admin/rooms.md): the room inspector that `liveRooms` and your custom widgets can deep-link into. --- Source: https://docs.colyseus.io/admin/dashboard # Resources & CRUD Every drizzle table you pass to the admin panel becomes a browse-able **resource** with a full CRUD UI. The UI includes a paginated list with search, filter and sort. It adds a detail view with related rows, create and edit forms, and per-row custom actions. The built-in tables from [`@colyseus/database`](https://docs.colyseus.io/database.md) are registered automatically. You customize them, and add your own, with the `tables` and `resources` options. ![The Guilds resource list with a toolbar action and per-row actions](https://docs.colyseus.io/admin/resource-list.webp) ## Built-in resources The built-in tables, in sidebar order: | Resource | Key | Source table | Notes | |---|---|---|---| | Users | `users` | `colyseus_users` | Detail page adds ban, unban and revoke-sessions buttons, plus an **Active sessions** tab (needs [`TrackUserSessionsPlugin`](https://docs.colyseus.io/room/plugins/track-user-sessions.md)). | | Configs | `configs` | `colyseus_configs` | — | | Leaderboards | `leaderboards` | `colyseus_leaderboards` | — | | Analytics Events | `analyticsEvents` | `colyseus_analytics_events` | — | | Cloud Saves | `cloudSaves` | `colyseus_cloud_saves` | — | | Leaderboard Entries | `leaderboardEntries` | `colyseus_leaderboard_entries` | — | | Roles | `roles` | `colyseus_roles` | Blocked for mods by the core role rule itself (not a policy). Modifying it would bypass the role system. | | User Notes | `userNotes` | `colyseus_user_notes` | `author_id` auto-filled from the session on create. | | Audit log | `adminAudit` | `colyseus_admin_audit` | Admin-only reads; every write denied, including for admins (append-only contract). | ![A user's detail page: columns, related-row tabs, the Active sessions list, and the per-user audit trail](https://docs.colyseus.io/admin/user-show.webp) The label derives from the key unless a definition overrides it. The **key** is what you use everywhere else: in URLs (`/admin/cloudSaves`, `/admin-api/cloudSaves`), in `db.moderation.assignMod(userId, "cloudSaves")`, and as the `resource` value in audit rows. Unless a table declares its own [policies](#policies), access follows the standard role rule. Only the audit log declares policies by default. - **admin**: full CRUD. - **mod**: list, read, and update, only on resources they're [scoped to](https://docs.colyseus.io/admin/authentication.md#scoping-a-moderator-to-specific-resources). - **user**: cannot open the panel UI (403). Direct API calls to list and read endpoints still succeed. ## Exposing a custom table Two options work together. `tables` decides **which** drizzle tables exist in the panel; `resources` decides **how** each one looks and behaves. A custom table needs both: a `resources` entry alone is stored but never listed, and `/admin-api/guilds` answers `404`. ```ts {2,6-7} filename="src/app.config.ts" import { admin, defineAdminResource } from "@colyseus/admin"; import { guilds } from "./db/schema"; routes: createRouter({ ...admin({ tables: { ...db.tables, guilds }, // what exists resources: { guilds: defineAdminResource(guilds, { label: "Guilds" }) }, // how it looks }), }), ``` `db.tables` holds the built-in tables keyed by their canonical names. The key you pick for a custom table (`guilds`) becomes its URL segment and its scope name for mods. A custom table with no `resources` entry still works: you get the humanized label, a default icon picked from the table name, and all columns. > **Note:** > > `GameDatabase` only creates its own tables at boot. Create custom tables with your migrations (see [Database → Schemas](https://docs.colyseus.io/database/schemas.md)) before pointing the panel at them. ## Customizing a resource Override the defaults of any table, built-in or custom, by passing `defineAdminResource(table, config)` into `resources`: ```ts {4,36-37} filename="src/app.config.ts" import { admin, defineAdminResource } from "@colyseus/admin"; import { guilds } from "./db/schema"; const guildResource = defineAdminResource(guilds, { label: "Guilds", icon: "safety", list: { columns: ["name", "owner_id", "member_count", "created_at"], defaultSort: { field: "created_at", order: "desc" }, }, form: { fields: ["name", "description", "owner_id"], }, columns: { created_at: { label: "Founded" }, }, actions: [{ name: "rename", label: "Rename guild", perRow: true, confirm: { title: "Rename this guild?" }, handler: async (row, { userId }) => { // ...rename logic return { renamedBy: userId }; }, roles: ["admin", "mod"], }], policies: { delete: ["admin"], // admin-only deletion }, }); routes: createRouter({ ...admin({ database: db, tables: { ...db.tables, guilds }, resources: { guilds: guildResource }, }), }), ``` > **Warning:** > > Column lists (`list.columns`, `form.fields`, `show.fields`) and the keys of `columns` are **SQL column names** (`owner_id`), not drizzle property names (`ownerId`). A property name matches nothing and is dropped without an error. ### Configuration reference ```ts defineAdminResource(table, { label, // sidebar/page title; defaults to the humanized key icon, // sidebar icon from the built-in icon set (see below) list: { columns, defaultSort }, form: { fields }, show: { fields }, create: { defaults }, columns: { : { label, linkTo } }, relations: { : { label } }, actions: [ /* ResourceAction[] */ ], policies: { list, read, create, update, delete }, }) ``` | Section | Field | Description | |---|---|---| | `list` | `columns` | Columns shown in the list view, in order. Primary keys are always included. Omit to show all. | | | `defaultSort` | `{ field, order: 'asc' \| 'desc' }` applied when no `_sort` is requested. Useful for append-only tables. | | `form` | `fields` | Columns shown in the create and edit forms. The create form additionally hides primary keys and columns with a database default, even when listed here. Omit to show all. | | `show` | `fields` | Columns shown on the read-only detail page. | | `create` | `defaults` | `(ctx) => Record \| Promise<…>`: server-side defaults merged into the body before INSERT. Receives `{ operatorId, resource }`. The request body wins on conflict. | | `columns` | `.label` | Override the auto-humanized column label. | | | `.linkTo` | Decorate the cell as a link to another resource's show page. Static: `{ resource: "users" }`; for the dynamic form, see [FK auto-linking](#fk-auto-linking). | | `relations` | `.label` | Override the auto-humanized relation tab label. | | `actions` | `ResourceAction[]` | Custom per-row or bulk operations. See below. | | `policies` | `list \| read \| create \| update \| delete` | Per-action RBAC. See [Policies](#policies). | Every column header in the list view sorts on click; there is no per-column opt-in. ### Server-side defaults on create The `create.defaults` function runs on every INSERT with the operator in scope. Use it to auto-fill audit fields like `author_id` or `created_by` from the session. ```ts create: { defaults: ({ operatorId }) => ({ authorId: operatorId }), } ``` Keys can be either SQL column names (`author_id`) or drizzle property names (`authorId`). Both forms are normalized. The request body wins on conflict, matching how SQL `DEFAULT`s behave per-column. `operatorId` is `undefined` when `enforceRbac` is off. ## Custom actions Custom actions are operations beyond create/update/delete: things like "reset a player's level", "refund a transaction", "rename a guild". They appear as buttons on the resource's list page: a toolbar button for bulk actions, a per-row menu for row actions. The detail page shows none. Each click resolves to a POST request: ``` POST /admin-api/:resource/_action/:name ``` ### Per-row actions Set `perRow: true` to make the action operate on a specific row. The panel sends `{ id }` and the handler receives the row data: ```ts actions: [{ name: "reset_progress", label: "Reset progress", perRow: true, confirm: { title: "Reset this player's progress?", description: "This clears all cloud saves and resets the user's level to 1.", }, handler: async (row, { userId, resource }) => { await db.saves.delete(row.id); await db.drizzle.update(users).set({ level: 1 }).where(eq(users.id, row.id)); return { ok: true }; }, roles: ["admin"], }] ``` The endpoint answers `{ ok: true, result }` with the handler's return value, JSON-serialized. The panel itself shows a generic success toast and only displays the body on failure. The return value is for API callers and the audit log. ### Bulk actions Omit `perRow` for actions that operate on the table at large (e.g. "reseed leaderboard", "purge old entries"). The panel sends `{}` and the handler receives `null` for the row argument. ### Confirmation prompts `confirm` is optional but recommended for destructive or expensive operations. Without it the panel still asks `Run "