UniqueSessionPlugin
Enforces a max number of concurrent sessions of this room type per user. Default max: 1 (strict singleton). A second join from the same user while the first is still active is either rejected or replaces the older session, depending on configuration.
Typical uses:
- Prevent a player from opening two tabs of the same game mode.
- Block re-queueing while an old matchmaking session is still tracked.
- Refresh-style UX where opening a new tab kicks the older one out.
Installation
The plugin ships with the colyseus package:
import { Room, definePlugins } from "colyseus";
import { UniqueSessionPlugin } from "colyseus/plugins/unique-session";
export class GameRoom extends Room {
plugins = definePlugins([
new UniqueSessionPlugin({ max: 1 }),
]);
}Auto-includes TrackUserSessionsPlugin. The plugin reads the per-user reverse index that TrackUserSessionsPlugin maintains in Presence. You don’t have to register the tracker yourself: static dependencies pulls it in.
How it works
When a client joins, the plugin:
- Resolves the user’s stable id (default:
client.auth.id, thenclient.auth.userId). - Reads the per-user reverse index from Presence to see what other rooms-of-this-type they’re in.
- Reconciles entries against live rooms (drops stragglers from crashed processes; cross-checks same-room entries against
this.room.clientsso a fast-disconnect ghost doesn’t block reconnects). - If the active count would exceed
max, either rejects the join or kicks the oldest existing session.
Anonymous clients (no resolvable userId) are never rejected, because there’s no stable identity to enforce against.
Performance
Per-join cost is bounded at two backend round trips, independent of cluster size:
HGETALLthe user’s reverse-index hash (always).- One batch room lookup (
matchMaker.findRoomsByIds) for any cross-room candidates, skipped entirely when the user has none. Entries pointing at this room are resolved against the room’s local client list, with no backend call.
So a typical first-join (no prior entries) is one wire op. A duplicate check against an existing same-room session is also one (the verification is local). Cross-room reconciliation adds one more batch call. The plugin’s cost scales with the user’s own session count (K), not the cluster’s total room count (N). The plugin is safe to enable on large multi-process deployments.
Options
| Option | Type | Default | Description |
|---|---|---|---|
max | number | 1 | Max concurrent sessions of this room type per user. |
onDuplicate | 'reject' | 'replace' | 'reject' | Behavior when the limit is exceeded. 'replace' kicks the oldest existing session(s) so the new join proceeds, useful for refresh-style UX. |
rejectCode | number | 4400 | ServerError code sent to the client when onDuplicate: 'reject'. The SDK surfaces it as MatchMakeError.code. |
rejectMessage | string | 'already_in_room' | ServerError message sent to the client. The SDK exposes it as e.message. |
conflictsWith | (existing, currentRoom) => boolean | — | Per-existing-session filter that runs after the plugin’s same-roomName matching + stale-entry reconcile. Return true to count the existing session as a conflict, false to skip it. See conflictsWith in detail below. |
resolveUserId | (client) => string | undefined | reads auth.id/auth.userId | Custom identity extractor. Override for non-standard auth shapes (e.g. raw OIDC’s auth.profile.sub). |
Handling the rejection on the client
The plugin throws a ServerError(4400, "already_in_room"). On the client:
try {
await client.joinOrCreate("game");
} catch (err) {
if (err.code === 4400) {
showAlreadyInGameDialog();
}
}For custom codes, configure rejectCode / rejectMessage.
Examples
Allow up to two concurrent tabs
plugins = definePlugins([
new UniqueSessionPlugin({ max: 2 }),
]);A third join from the same user is rejected.
Replace the older session on duplicate
plugins = definePlugins([
new UniqueSessionPlugin({
max: 1,
onDuplicate: "replace",
}),
]);The oldest existing session is kicked with WS code 1000 and reason 'replaced'. The new join proceeds.
Scope by conflictsWith
The conflictsWith callback is your hook to override “what counts as a duplicate.” The plugin has already filtered out entries with a different roomName and reconciled stale entries. Whatever reaches the predicate is a real, live session of the same room type. Return true to count it against max, false to ignore.
The predicate receives:
existing: the candidate session being evaluated:sessionId: string: the existing session’s id.joinedAt: number: Unix ms timestamp from when the existing session was recorded.room?: IRoomCache: matchmaker’s cached record for the existing session’s room (metadata,clients,maxClients,locked,processId, etc.). Undefined when the existing session is in the sameRoominstance as the joining client. UsecurrentRoomfor that case. No extra round trip is made: this is the same lookup the plugin already runs for cross-room candidates.
currentRoom: the liveRoominstance the join is targeting (same asthis.roominside the plugin). Optional to declare; ignore it if you don’t need it.
Scope by game mode metadata
plugins = definePlugins([
new UniqueSessionPlugin({
max: 1,
// Only count sessions in rooms of the same game mode. Casual
// and ranked rooms share a roomName but should run side-by-side.
conflictsWith: (existing, currentRoom) => {
const otherMode = existing.room?.metadata?.mode ?? currentRoom.metadata?.mode;
return otherMode === currentRoom.metadata?.mode;
},
}),
]);Per-process exemption (multi-region)
import { matchMaker } from "@colyseus/core";
plugins = definePlugins([
new UniqueSessionPlugin({
max: 1,
// Allow one session per Node process, useful if you run the
// game in multiple regions and want a user to be able to play
// in EU and US at the same time.
// same-room conflicts have no `existing.room`: always same-process
conflictsWith: (existing) =>
!existing.room || existing.room.processId === matchMaker.processId,
}),
]);Don’t count rooms that are already full
plugins = definePlugins([
new UniqueSessionPlugin({
max: 1,
// If the other room is full, the user can't actually still be
// playing meaningfully, so let them re-queue elsewhere.
conflictsWith: (existing) =>
!existing.room || existing.room.clients < existing.room.maxClients,
}),
]);Custom auth shape
plugins = definePlugins([
new UniqueSessionPlugin({
resolveUserId: (client) => (client as any).auth?.profile?.sub,
}),
]);Caveats
- Race window. Two near-simultaneous joins from the same user can both pass the check before either has written to the Presence index, so both succeed. Acceptable for the “no two tabs” UX. If your game needs strict serialization (e.g. competitive matchmaking that can’t double-book), layer your own atomic reservation on top.
TrackUserSessionsPluginis required. Auto-included viastatic dependencies. If your app’sRoomsomehow opts out of plugin tracking, the index stays empty and this plugin no-ops.- Stale entries are best-effort cleaned up. Reconciliation drops entries pointing at rooms the matchmaker no longer knows. Same-room entries are cross-checked against
this.room.clientsso reconnects after a missedonLeavearen’t false-rejected.