Services
Each GameDatabase instance exposes a set of services, read-and-write APIs over the built-in tables. All examples below assume the exported db instance from the Quick start.
Cloud saves
Per-user save data with monotonic versioning. The slot is optional and defaults to 0. Pass a different slot for multiple save files per player. Pass an expectedVersion for optimistic locking; a stale write throws VersionConflictError.
import { VersionConflictError } from "@colyseus/database";
// save(userId, data, slot = 0, expectedVersion?)
const { version } = await db.saves.save(userId, { hp: 100, level: 7 });
const save = await db.saves.load(userId); // { data, version } | null
try {
// pass the expected version for optimistic locking
await db.saves.save(userId, newData, 0, version);
} catch (e) {
if (e instanceof VersionConflictError) {
// another write landed in between: reload and merge
}
}
await db.saves.listSlots(userId); // [{ slot, version, updatedAt }]
await db.saves.delete(userId); // delete slot 0 for this userLeaderboards
Keep-best scoring with optional seasons.
await db.leaderboards.ensure("global", "Global Leaderboard");
await db.leaderboards.submit("global", userId, 1750); // only kept if it beats the current best
await db.leaderboards.submit("global", userId, 999, "season-1"); // isolated per season
const top = await db.leaderboards.top("global", 10);
const nearby = await db.leaderboards.aroundMe("global", userId, 5);Live configs
Declare typed configuration keys with Standard Schema validators (Zod, Valibot, etc.) via defineConfigs. Values are validated on write and cached in memory. When a presence is provided, the cache is invalidated across all server instances when a value changes (e.g. from an admin panel).
A key with no row yet resolves to the schema’s default. With Zod 4, use .prefault({}) on the object: .default({}) returns {} as-is, without the field defaults.
import { defineServer, RedisPresence } from "colyseus";
import { GameDatabase, defineConfigs } from "@colyseus/database";
import { z } from "zod";
const configs = defineConfigs({
matchmaking: z.object({
minPlayers: z.number().int().default(2),
maxPlayers: z.number().int().default(10),
}).prefault({}), // no row yet: parse {} so the field defaults fill in
});
export const db = new GameDatabase({
connectionString: process.env.DATABASE_URL,
configsRegistry: configs,
presence: new RedisPresence(), // cross-instance invalidation (optional)
});const mm = await db.configs.get("matchmaking"); // typed: { minPlayers, maxPlayers }
const unsubscribe = db.configs.subscribe("matchmaking", (value) => {
// re-applied live whenever an admin changes it
});Analytics, moderation & notes
await db.analytics.track("match_completed", userId, { mode: "ranked", durationMs: 312_000 });
await db.moderation.setRole(userId, "mod");
await db.moderation.assignMod(userId, "guilds"); // mods act only on assigned collections
await db.moderation.can(userId, "read", "guilds"); // true
await db.moderation.can(userId, "delete", "guilds"); // false: "delete" is admin-only
await db.notes.add(userId, "Refunded once on 2026-04-12", authorId);
await db.notes.deleteAllForUser(userId); // GDPR cleanupRoom plugins
For common patterns, @colyseus/database ships Room Plugins that wire the services above into the room lifecycle:
CloudSavesPlugin: load a player’s save on join, persist it on leaveLeaderboardsPlugin: submit scores to one boardAnalyticsPlugin: track roomcreate/join/leave/disposeevents
Plugin hooks run in a fixed order relative to the room’s own hooks. The plugin’s onJoin runs before the room’s onJoin, and its onLeave runs after the room’s onLeave. A room that creates the player entry in onJoin and removes it in onLeave therefore has no entry when the automatic load or save runs. Turn the automatic hooks off and call the plugin from the room, after creating the entry and before removing it:
import { Room, definePlugins } from "colyseus";
import { CloudSavesPlugin, LeaderboardsPlugin, AnalyticsPlugin } from "@colyseus/database";
import { GameState, Player } from "./schema";
import { db } from "../app.config";
export class MyRoom extends Room {
state = new GameState();
plugins = definePlugins([
new CloudSavesPlugin({
database: db,
onJoin: "none", // load after this room creates the player (see onJoin below)
onLeave: "none", // save before this room removes it (see onLeave below)
payload: (room, client) => room.state.players.get(client.sessionId).toJSON(),
apply: (room, client, data) => room.state.players.get(client.sessionId).assign(data),
}),
new LeaderboardsPlugin({ database: db, boardId: "arena", submitOn: "none" }),
new AnalyticsPlugin({ database: db, prefix: "arena", track: ["join", "leave"] }),
]);
async onJoin(client) {
this.state.players.set(client.sessionId, new Player());
await this.plugins.cloudSaves.manualLoad(client);
}
async onLeave(client) {
const player = this.state.players.get(client.sessionId);
await this.plugins.cloudSaves.manualSave(client);
await this.plugins.leaderboards.submitScore(client, player.score);
this.state.players.delete(client.sessionId);
}
}The plugins resolve the user id from client.auth.id (set by the default onAuth of @colyseus/auth); pass resolveUserId to read it from elsewhere. AnalyticsPlugin needs no per-player state, so its automatic hooks work as-is: the example above writes arena.join and arena.leave rows.
LeaderboardsPlugin’s default submitOn: "dispose" submits the score of every client still connected when the room disposes. A room that disposes after its last player left has no clients by then, so submit from onLeave as above.
See Room Plugins for how plugins attach and expose methods.