Server Guide
All Room settings are defined in edgebase.config.ts under the rooms key. Each key is a namespace (e.g. 'game', 'lobby'), and clients connect with client.room(namespace, roomId).
The ctx.admin examples in this guide map to the same server-side capabilities exposed by all Admin SDKs.
Room Configuration
import { defineConfig } from '@edgebase/shared';
export default defineConfig({
rooms: {
'game': {
maxPlayers: 10,
onCreate(room, ctx) {
room.setSharedState(() => ({ turn: 0, score: 0 }));
},
onJoin(sender, room, ctx) {
room.setPlayerState(sender.userId, () => ({ hp: 100 }));
},
onAction: {
SET_SCORE: (payload, room, sender, ctx) => {
room.setSharedState(s => ({ ...s, score: payload.score }));
return { score: payload.score };
},
},
},
},
});
Namespace Options
| Option | Type | Default | Description |
|---|---|---|---|
maxPlayers | number | 100 | Maximum concurrent connections per room instance (1 -- 32768) |
maxStateSize | number (bytes) | 1048576 (1 MB) | Maximum combined size of sharedState + all playerStates. Min 1 KB. |
reconnectTimeout | number (ms) | 30000 | Grace period before onLeave fires after disconnect. 0 = immediate. |
rateLimit | { actions: number } | { actions: 10 } | Token-bucket rate limit for send() calls per second per connection |
stateSaveInterval | number (ms) | 60000 | How often state is saved to DO Storage. Lower = less data loss, more writes. |
stateTTL | number (ms) | 86400000 | How long saved state remains valid. After expiry, state is auto-deleted. |
Lifecycle Hooks
| Hook | Signature | Description |
|---|---|---|
onCreate | (room, ctx) => void | Called once when the first player joins. Initialize state here. |
onJoin | (sender, room, ctx) => void | Called each time a player joins. Throw to reject the join. |
onLeave | (sender, room, ctx, reason) => void | Called when a player leaves. reason: 'leave' | 'disconnect' | 'kicked' |
onDestroy | (room, ctx) => void | Called after the last player leaves, before hibernation. Persist results here. |
All hooks can be async.
rooms: {
'game': {
onCreate(room, ctx) {
room.setSharedState(() => ({
phase: 'waiting',
round: 0,
}));
room.setServerState(() => ({
seed: Math.random(),
}));
},
onJoin(sender, room, ctx) {
// Reject unauthorized players
if (sender.role === 'banned') {
throw new Error('You are banned');
}
room.setPlayerState(sender.userId, () => ({
hp: 100,
position: { x: 0, y: 0 },
}));
},
onLeave(sender, room, ctx, reason) {
room.setSharedState(s => ({
...s,
lastLeave: { userId: sender.userId, reason },
}));
},
async onDestroy(room, ctx) {
const state = room.getSharedState();
await ctx.admin.db('shared').table('game_results').insert({
round: state.round,
finalScore: state.score,
});
},
},
}
Action Handlers
All state mutations go through server-side action handlers. Clients call room.send(type, payload), the server runs the matching handler, mutates state, and broadcasts changes automatically.
Client Server
│ │
│── send('type', payload) ────────▶│
│ │ onAction['type'] runs
│ │ setSharedState / setPlayerState / setServerState
│◀── action_result (return value) ─│
│◀── shared_delta / player_delta ──│ (auto-broadcast)
Defining Handlers
onAction: {
MOVE: (payload, room, sender, ctx) => {
room.setPlayerState(sender.userId, s => ({
...s,
position: { x: payload.x, y: payload.y },
}));
return { moved: true };
},
ATTACK: async (payload, room, sender, ctx) => {
const target = room.player(payload.targetId);
if (!target) throw new Error('Target not found');
room.setPlayerState(payload.targetId, s => ({
...s,
hp: Math.max(0, (s.hp as number) - 10),
}));
return { damage: 10 };
},
CHAT: (payload, room, sender) => {
room.sendMessage('chat', {
from: sender.userId,
text: payload.text,
});
},
},
Handler Signature
(payload: unknown, room: RoomServerAPI, sender: RoomSender, ctx: RoomHandlerContext)
=> Promise<unknown> | unknown;
payload
The data sent by the client via room.send(type, payload). Can be any JSON-serializable value. Always validate before use.
sender — RoomSender
| Field | Type | Description |
|---|---|---|
sender.userId | string | Authenticated user ID (from JWT sub) |
sender.connectionId | string | Unique connection ID for this WebSocket session |
sender.role | string? | User role from JWT (if set) |
Return Values and Errors
The handler's return value is sent back only to the client that sent the action:
onAction: {
BET: (payload, room) => {
room.setSharedState(s => ({ ...s, pot: (s.pot as number) + payload.amount }));
return { newPot: (room.getSharedState().pot as number) + payload.amount };
},
}
// Client receives: { type: 'action_result', result: { newPot: 150 }, requestId: '...' }
onAction: {
BET: (payload, room) => {
if (payload.amount > 100) {
throw new Error('Bet exceeds maximum');
}
},
}
// Client receives: { type: 'action_error', message: 'Bet exceeds maximum', requestId: '...' }
The onAction handler has a 5-second timeout. If it does not return within that window, the client receives an action_error.
RoomServerAPI
The room parameter in all hooks and action handlers provides the server-side API.
Shared State (visible to all clients)
| Method | Description |
|---|---|
room.getSharedState() | Returns a read-only snapshot of the current shared state. |
room.setSharedState(updater) | Mutate shared state. updater: (state) => newState. Delta auto-broadcast to all clients. |
// Read
const state = room.getSharedState();
console.log(state.score);
// Write (updater function pattern)
room.setSharedState(s => ({ ...s, score: (s.score as number) + 1 }));
Player State (visible only to the owning player)
| Method | Description |
|---|---|
room.player(userId) | Get a specific player's state (read-only snapshot). |
room.players() | Get all players as [userId, state][]. |
room.setPlayerState(userId, updater) | Mutate a player's state. Delta unicast to that player only. |
// Read one player
const hp = room.player(sender.userId).hp;
// Read all players
const allPlayers = room.players(); // [['user1', {...}], ['user2', {...}]]
// Write
room.setPlayerState(sender.userId, s => ({ ...s, hp: (s.hp as number) - 10 }));
Server State (server-only, never sent to clients)
| Method | Description |
|---|---|
room.getServerState() | Returns a snapshot of server-only state. |
room.setServerState(updater) | Mutate server-only state. No broadcast. |
room.setServerState(s => ({ ...s, seed: Math.random() }));
const secret = room.getServerState().seed;
serverState is automatically persisted alongside sharedState and playerState. It survives Durable Object hibernation. Use it for RNG seeds, anti-cheat counters, and internal computation caches.
Messaging and Control
| Method | Description |
|---|---|
room.sendMessage(type, data?, options?) | Broadcast a one-off message to all connected clients. options.exclude: array of userIds to skip. |
room.sendMessageTo(userId, type, data?) | Send a one-off message to a specific user only. |
room.kick(userId) | Forcefully disconnect a player. Triggers onLeave with reason='kicked'. |
room.saveState() | Force immediate state save to DO Storage. |
room.setTimer(name, ms, data?) | Schedule a named timer. Calls onTimer[name] after ms milliseconds. |
room.clearTimer(name) | Cancel a named timer. No-op if timer doesn't exist. |
room.setMetadata(data) | Set developer-defined metadata (queryable via HTTP without joining). |
room.getMetadata() | Get current room metadata. |
// Broadcast a message (clients receive via room.onMessage('game_over', handler))
room.sendMessage('game_over', { winner: sender.userId });
// Broadcast excluding specific users
room.sendMessage('player_moved', { userId: sender.userId, x: 10 }, {
exclude: [sender.userId],
});
// Send to a specific player (clients receive via room.onMessage)
room.sendMessageTo(payload.targetUserId, 'private_hint', { hint: 'look left' });
// Kick a player
room.kick(payload.targetUserId);
// Force save after critical change
await room.saveState();
Messages are fire-and-forget — they are not persisted in state. Use them for ephemeral events like chat, sound effects, or notifications. sendMessageTo sends to all connections of the specified user (same format as sendMessage).
Player Tracking
Room does not automatically expose player information to clients. This is a security decision — the developer controls exactly what player data is shared via setSharedState.
Server
rooms: {
'game': {
onJoin(sender, room) {
room.setSharedState(s => ({
...s,
players: [...(s.players || []), { id: sender.userId }],
}));
},
onLeave(sender, room) {
room.setSharedState(s => ({
...s,
players: (s.players || []).filter(p => p.id !== sender.userId),
}));
},
},
}
Client
room.onSharedState((state) => {
renderPlayerList(state.players);
});
Everything in sharedState is broadcast to all connected clients.
Only include publicly safe information (nicknames, avatars, etc.).
Never include emails, tokens, or other sensitive data.
For data that only a specific player should see, use setPlayerState — it is unicast to the owning player only.
Reconnect
When a client disconnects, the server waits for reconnectTimeout before firing onLeave. If the client reconnects within this window, the session resumes with state preserved.
rooms: {
'game': {
reconnectTimeout: 15000, // 15 seconds grace period
},
'ephemeral-chat': {
reconnectTimeout: 0, // Immediate onLeave on disconnect
},
}
reconnectTimeout: 0—onLeavefires immediately on disconnect.- Client SDKs handle auto-reconnect automatically (exponential backoff).
onLeavereceivesreason:'leave'— client calledroom.leave()explicitly'disconnect'— WebSocket dropped and reconnect timeout expired'kicked'— server calledroom.kick(userId)
Admin Context (ctx)
Admin context for cross-DO operations. Bypasses all access rules.
| Property | Description |
|---|---|
ctx.admin.db(namespace, id?) | Access a database namespace. Returns a DB proxy. |
ctx.admin.push.send(userId, payload) | Send a push notification to a user. |
ctx.admin.push.sendMany(userIds, payload) | Send push notifications to multiple users. |
ctx.admin.broadcast(channel, event, data?) | Broadcast on a realtime channel (outside Room). |
Database Access
Use ctx.admin.db() to read/write databases from within action handlers:
onAction: {
SAVE_SCORE: async (payload, room, sender, ctx) => {
const state = room.getSharedState();
// Write to database (cross-DO, may have latency)
await ctx.admin.db('shared').table('scores').insert({
usedId: sender.userId,
score: state.score,
});
return { saved: true };
},
LOAD_INVENTORY: async (payload, room, sender, ctx) => {
// Read from a per-user database
const items = await ctx.admin
.db('user', sender.userId)
.table('inventory')
.list();
room.setPlayerState(sender.userId, s => ({
...s,
inventory: items,
}));
return { itemCount: items.length };
},
},
The DB proxy supports: get(id), list(filter?), insert(data), update(id, data), delete(id).
ctx.admin operations bypass all access rules and use service-key-level access. Validate payload before using it in database operations. Never expose raw admin results to clients without filtering.
Database operations go through Durable Object stubs (cross-DO), which can introduce latency of several seconds. Use void ctx.admin.db(...).catch(...) for fire-and-forget writes when the result is not needed for the action response.
Timers
Schedule named timers in action handlers. Essential for game logic — turn timers, countdowns, delayed effects.
Defining Timer Handlers
rooms: {
'game': {
onTimer: {
turnEnd: (room, ctx) => {
room.setSharedState(s => ({ ...s, phase: 'next_turn' }));
room.sendMessage('turn_ended', {});
},
countdown: (room, ctx, data) => {
room.sendMessage('countdown_tick', { remaining: data.remaining });
if (data.remaining > 0) {
room.setTimer('countdown', 1000, { remaining: data.remaining - 1 });
}
},
},
onAction: {
START_TURN: (payload, room) => {
room.setTimer('turnEnd', 30000); // 30s turn timer
},
CANCEL_TURN: (payload, room) => {
room.clearTimer('turnEnd');
},
START_COUNTDOWN: (payload, room) => {
room.setTimer('countdown', 1000, { remaining: 10 });
},
},
},
},
Timer API
| Method | Description |
|---|---|
room.setTimer(name, ms, data?) | Schedule a named timer. Calls onTimer[name] after ms milliseconds. Overwrites existing timer with same name. |
room.clearTimer(name) | Cancel a named timer. No-op if timer doesn't exist. |
Timer Handler Signature
(room: RoomServerAPI, ctx: RoomHandlerContext, data?: unknown) => Promise<void> | void;
- No
senderparameter — timers are not triggered by a player. datais the optional payload passed tosetTimer().- Timers survive DO hibernation (persisted to storage).
- Timer handlers do not count against action rate limits.
- A timer can schedule another timer (e.g., recurring countdown).
- Setting a timer with the same name overwrites the previous one.
Room Metadata
Developer-defined metadata that can be queried via HTTP without joining the room. Useful for lobby screens (player count, game mode, etc.).
Server
rooms: {
'game': {
onCreate(room) {
room.setMetadata({ mode: 'classic', playerCount: 0 });
},
onJoin(sender, room) {
const meta = room.getMetadata();
room.setMetadata({ ...meta, playerCount: (meta.playerCount as number) + 1 });
},
onLeave(sender, room) {
const meta = room.getMetadata();
room.setMetadata({ ...meta, playerCount: Math.max(0, (meta.playerCount as number) - 1) });
},
},
},
Client (HTTP GET, no WebSocket needed)
// Via client instance
const meta = await client.getRoomMetadata('game', 'room-123');
console.log(meta.mode, meta.playerCount);
// Via static method
const meta = await RoomClient.getMetadata(baseUrl, 'game', 'room-123');
// Via room instance (before or after joining)
const room = client.room('game', 'room-123');
const meta = await room.getMetadata();
REST API
GET /api/room/metadata?namespace={ns}&id={roomId}
Returns JSON object. No authentication required — developers control what data is exposed via room.setMetadata().
Metadata is accessible without authentication. Never include tokens, emails, or other sensitive data. Only include information you want lobby/matchmaking screens to see.
Full Example
import { defineConfig } from '@edgebase/shared';
export default defineConfig({
rooms: {
'battle': {
maxPlayers: 10,
maxStateSize: 512 * 1024, // 512 KB
reconnectTimeout: 15000, // 15 seconds
rateLimit: { actions: 20 }, // 20 actions/sec per player
stateSaveInterval: 30000, // Save every 30 seconds
stateTTL: 7200000, // 2 hours
onCreate(room, ctx) {
room.setSharedState(() => ({
round: 0,
phase: 'waiting',
players: [],
}));
room.setServerState(() => ({
seed: Math.random(),
}));
room.setMetadata({ mode: 'battle', playerCount: 0 });
},
onJoin(sender, room, ctx) {
if (sender.role === 'banned') {
throw new Error('You are banned');
}
room.setSharedState(s => ({
...s,
players: [...(s.players || []), { id: sender.userId }],
}));
room.setPlayerState(sender.userId, () => ({
hp: 100,
position: { x: 0, y: 0 },
}));
const meta = room.getMetadata();
room.setMetadata({ ...meta, playerCount: (meta.playerCount as number) + 1 });
},
onLeave(sender, room, ctx, reason) {
room.setSharedState(s => ({
...s,
players: (s.players || []).filter(p => p.id !== sender.userId),
lastLeave: { userId: sender.userId, reason },
}));
const meta = room.getMetadata();
room.setMetadata({ ...meta, playerCount: Math.max(0, (meta.playerCount as number) - 1) });
},
async onDestroy(room, ctx) {
const state = room.getSharedState();
await ctx.admin.db('shared').table('game_results').insert({
round: state.round,
finalScore: state.score,
});
},
onTimer: {
turnEnd: (room) => {
room.setSharedState(s => ({
...s,
round: (s.round as number) + 1,
phase: 'waiting',
}));
room.sendMessage('turn_ended', { round: room.getSharedState().round });
},
},
onAction: {
ATTACK: (payload, room, sender) => {
const damage = 10;
room.setPlayerState(payload.targetId, s => ({
...s,
hp: Math.max(0, (s.hp as number) - damage),
}));
// Notify everyone except the attacker
room.sendMessage('player_attacked', {
attacker: sender.userId,
target: payload.targetId,
damage,
}, { exclude: [sender.userId] });
return { damage };
},
MOVE: (payload, room, sender) => {
room.setPlayerState(sender.userId, s => ({
...s,
position: { x: payload.x, y: payload.y },
}));
return { moved: true };
},
NEXT_ROUND: (payload, room) => {
room.setSharedState(s => ({
...s,
round: (s.round as number) + 1,
phase: 'playing',
}));
// Start 30s turn timer
room.setTimer('turnEnd', 30000);
},
async CRITICAL_UPDATE(payload, room) {
room.setSharedState(s => ({ ...s, ...payload }));
// Force immediate save for critical data
await room.saveState();
},
KICK_PLAYER: (payload, room, sender) => {
if (sender.role !== 'admin') throw new Error('Not authorized');
room.kick(payload.userId);
},
},
},
},
});