Advanced Patterns
This feature is in beta. Core behavior is stable, but some APIs or configuration may change before general availability.
This guide covers real-world patterns built on top of Room's core capabilities: State, Members, and Signals. Each pattern shows both server-side configuration and client-side usage.
Multiplayer Game State
Games typically split data between shared state (the board, scores, turn order) and player state (each player's hand, HP, inventory). The server is the sole authority on both.
Server Configuration
import { defineConfig } from '@edgebase/shared';
export default defineConfig({
rooms: {
'game': {
maxPlayers: 8,
reconnectTimeout: 15000,
rateLimit: { actions: 20 },
handlers: {
lifecycle: {
onCreate(room) {
room.setSharedState(() => ({
phase: 'lobby',
turn: null,
board: Array(9).fill(null),
players: [],
}));
room.setServerState(() => ({
turnOrder: [],
moveHistory: [],
}));
},
onJoin(sender, room) {
const shared = room.getSharedState();
if (shared.phase !== 'lobby') {
throw new Error('Game already in progress');
}
room.setSharedState(s => ({
...s,
players: [...(s.players as any[]), {
id: sender.userId,
score: 0,
connected: true,
}],
}));
room.setPlayerState(sender.userId, () => ({
hand: [],
hp: 100,
inventory: [],
}));
},
onLeave(sender, room, _ctx, reason) {
if (reason === 'disconnect') {
// Mark as disconnected but keep in game during reconnect window
room.setSharedState(s => ({
...s,
players: (s.players as any[]).map(p =>
p.id === sender.userId ? { ...p, connected: false } : p
),
}));
} else {
// Full removal on explicit leave or kick
room.setSharedState(s => ({
...s,
players: (s.players as any[]).filter(p => p.id !== sender.userId),
}));
}
},
},
actions: {
START_GAME: (_payload, room) => {
const players = room.getSharedState().players as any[];
if (players.length < 2) throw new Error('Need at least 2 players');
const turnOrder = players.map(p => p.id);
room.setServerState(s => ({ ...s, turnOrder }));
room.setSharedState(s => ({
...s,
phase: 'playing',
turn: turnOrder[0],
}));
room.setTimer('turnTimeout', 30000);
},
PLACE_PIECE: (payload, room, sender) => {
const shared = room.getSharedState();
if (shared.turn !== sender.userId) throw new Error('Not your turn');
if ((shared.board as any[])[payload.index] !== null) {
throw new Error('Cell occupied');
}
const board = [...(shared.board as any[])];
board[payload.index] = sender.userId;
// Advance turn
const server = room.getServerState();
const order = server.turnOrder as string[];
const nextIdx = (order.indexOf(sender.userId) + 1) % order.length;
room.setSharedState(s => ({
...s,
board,
turn: order[nextIdx],
}));
room.clearTimer('turnTimeout');
room.setTimer('turnTimeout', 30000);
return { placed: payload.index };
},
},
timers: {
turnTimeout(room) {
// Auto-skip turn when player takes too long
const shared = room.getSharedState();
const server = room.getServerState();
const order = server.turnOrder as string[];
const currentIdx = order.indexOf(shared.turn as string);
const nextIdx = (currentIdx + 1) % order.length;
room.setSharedState(s => ({ ...s, turn: order[nextIdx] }));
room.sendMessage('turn_skipped', { userId: shared.turn });
room.setTimer('turnTimeout', 30000);
},
},
},
},
},
});
Client Usage
const room = client.room('game', 'lobby-42');
await room.join();
// Render the board whenever shared state changes
room.state.onSharedChange((state) => {
renderBoard(state.board);
highlightCurrentTurn(state.turn);
updatePlayerList(state.players);
});
// Render private data (hand, HP) from player state
room.state.onMineChange((state) => {
renderHand(state.hand);
renderHP(state.hp);
});
// Handle turn skips
room.signals.on('turn_skipped', (data) => {
showToast(`${data.userId}'s turn was skipped`);
});
// Place a piece
await room.state.send('PLACE_PIECE', { index: 4 });
Key Takeaways
- Shared state holds the board, scores, and public player info visible to everyone.
- Player state holds per-player secrets (hand, inventory) that only the owning player can see.
- Server state holds internal data like turn order and move history that clients never see.
- Timers handle turn timeouts without client involvement.
Collaborative Editing
Collaborative editing uses a combination of authoritative state for the document and signals for ephemeral cursor/selection data. The server validates and merges edits, preventing conflicts.
Last-Write-Wins with Sections
A practical approach for structured documents is to lock edits at the section level. Only one user can edit a section at a time.
rooms: {
'document': {
maxPlayers: 50,
handlers: {
lifecycle: {
onCreate(room) {
room.setSharedState(() => ({
sections: {},
sectionOrder: [],
locks: {}, // { sectionId: userId }
}));
},
},
actions: {
LOCK_SECTION: (payload, room, sender) => {
const locks = room.getSharedState().locks as Record<string, string>;
if (locks[payload.sectionId] && locks[payload.sectionId] !== sender.userId) {
throw new Error('Section is locked by another user');
}
room.setSharedState(s => ({
...s,
locks: { ...(s.locks as any), [payload.sectionId]: sender.userId },
}));
// Auto-unlock after 5 minutes of inactivity
room.setTimer(`unlock-${payload.sectionId}`, 300000, {
sectionId: payload.sectionId,
});
return { locked: true };
},
UPDATE_SECTION: (payload, room, sender) => {
const locks = room.getSharedState().locks as Record<string, string>;
if (locks[payload.sectionId] !== sender.userId) {
throw new Error('You do not hold the lock for this section');
}
room.setSharedState(s => ({
...s,
sections: {
...(s.sections as any),
[payload.sectionId]: {
content: payload.content,
lastEditedBy: sender.userId,
updatedAt: Date.now(),
},
},
}));
// Reset the auto-unlock timer
room.setTimer(`unlock-${payload.sectionId}`, 300000, {
sectionId: payload.sectionId,
});
return { updated: true };
},
UNLOCK_SECTION: (payload, room, sender) => {
const locks = room.getSharedState().locks as Record<string, string>;
if (locks[payload.sectionId] !== sender.userId) {
throw new Error('You do not hold the lock');
}
const newLocks = { ...(locks as any) };
delete newLocks[payload.sectionId];
room.setSharedState(s => ({ ...s, locks: newLocks }));
room.clearTimer(`unlock-${payload.sectionId}`);
},
},
timers: {
// Timer names are dynamic: 'unlock-{sectionId}'
// Timer handler names must match exactly, so use a single handler:
},
},
},
}
Client Usage
const room = client.room('document', 'doc-abc');
await room.join();
// Watch document changes
room.state.onSharedChange((state) => {
renderDocument(state.sections, state.sectionOrder);
renderLockIndicators(state.locks);
});
// Show collaborator cursors via member state
room.members.onSync((members) => {
renderCollaboratorAvatars(members);
});
room.members.onStateChange((member, state) => {
renderCursor(member.memberId, state?.cursor);
});
// Broadcast your cursor position as ephemeral member state
document.addEventListener('mousemove', debounce((e) => {
room.members.setState({ cursor: { x: e.clientX, y: e.clientY } });
}, 50));
// Edit flow
await room.state.send('LOCK_SECTION', { sectionId: 'intro' });
await room.state.send('UPDATE_SECTION', {
sectionId: 'intro',
content: 'Updated introduction text...',
});
await room.state.send('UNLOCK_SECTION', { sectionId: 'intro' });
Presence Indicators
Presence indicators show who is online, what they are doing, and when they are typing. This pattern uses room.members for the roster and ephemeral member state for activity.
Who's Online
// Client: render the online roster
room.members.onSync((members) => {
const onlineUsers = members.map(m => ({
id: m.memberId,
name: m.displayName,
avatar: m.avatarUrl,
status: m.state?.status ?? 'online',
}));
renderOnlineList(onlineUsers);
});
room.members.onJoin((member) => {
showToast(`${member.displayName} joined`);
});
room.members.onLeave((member, reason) => {
showToast(`${member.displayName} left (${reason})`);
});
Typing Indicators
Use member state for lightweight, ephemeral typing status. Member state is automatically cleaned up when the user leaves.
// Client: broadcast typing status
let typingTimeout: ReturnType<typeof setTimeout>;
inputElement.addEventListener('input', () => {
room.members.setState({ typing: true });
clearTimeout(typingTimeout);
typingTimeout = setTimeout(() => {
room.members.setState({ typing: false });
}, 2000);
});
// Client: render typing indicators
room.members.onStateChange((member, state) => {
if (state?.typing) {
showTypingIndicator(member.memberId);
} else {
hideTypingIndicator(member.memberId);
}
});
Custom Status
// Set rich presence
await room.members.setState({
status: 'away',
activity: 'Editing section 3',
lastActiveAt: Date.now(),
});
// Clear on focus
window.addEventListener('focus', () => {
room.members.setState({ status: 'online', activity: null });
});
window.addEventListener('blur', () => {
room.members.setState({ status: 'away' });
});
Members vs State for Presence
| Use case | Best tool |
|---|---|
| Who is online right now | room.members.onSync |
| Typing / cursor / ephemeral activity | room.members.setState |
| Persistent player data (HP, score) | room.state (playerState) |
| Chat messages, notifications | room.signals |
Room Lifecycle
Understanding the full lifecycle of a Room connection helps you build robust reconnection UX and clean resource management.
Connection Flow
client.room('ns', 'id') Create room reference (no connection yet)
|
room.join() Open WebSocket, authenticate, join
|
room.session.onConnectionStateChange('connected')
|
+-- room.state.onSharedChange(...) Start receiving state
+-- room.members.onSync(...) Start receiving roster
+-- room.signals.on(...) Start receiving signals
|
~ normal operation ~
|
+-- WebSocket drops
| |
| room.session.onConnectionStateChange('reconnecting')
| |
| +-- SDK auto-reconnects (exponential backoff)
| |
| room.session.onReconnect({ attempt })
| room.session.onConnectionStateChange('connected')
| |
| ~ state re-synced automatically ~
|
room.leave() Clean disconnect
|
room.session.onConnectionStateChange('disconnected')
Handling Reconnection
const room = client.room('game', 'lobby-1', {
autoReconnect: true,
maxReconnectAttempts: 10,
reconnectBaseDelay: 1000,
});
await room.join();
// Track connection state for UI
room.session.onConnectionStateChange((state) => {
switch (state) {
case 'connected':
hideReconnectBanner();
break;
case 'reconnecting':
showReconnectBanner('Reconnecting...');
break;
case 'disconnected':
showReconnectBanner('Disconnected. Please refresh.');
break;
}
});
room.session.onReconnect(({ attempt }) => {
console.log(`Reconnect attempt ${attempt}`);
});
// Handle being kicked
room.session.onKicked(() => {
showModal('You were removed from this room.');
navigateToLobby();
});
// Handle errors
room.session.onError((err) => {
if (err.code === 'RATE_LIMITED') {
showToast('Slow down! Too many actions.');
}
});
Clean Resource Management
// Store subscriptions for cleanup
const subs = [
room.state.onSharedChange((state) => render(state)),
room.members.onSync((members) => renderRoster(members)),
room.signals.on('chat', (msg) => appendChat(msg)),
];
// On component unmount or navigation
function cleanup() {
subs.forEach(sub => sub.unsubscribe());
room.leave();
}
Server-Side Lifecycle
handlers: {
lifecycle: {
onCreate(room) {
// First player joined -- initialize state
room.setSharedState(() => ({ /* ... */ }));
},
onJoin(sender, room) {
// Validate and set up player
// Throw to reject the join
},
onLeave(sender, room, _ctx, reason) {
// reason: 'leave' | 'disconnect' | 'kicked'
// 'disconnect' only fires after reconnectTimeout expires
},
async onDestroy(room, ctx) {
// Last player left -- persist results, clean up
const state = room.getSharedState();
await ctx.admin.db('app').table('game_results').insert({
finalState: state,
endedAt: new Date().toISOString(),
});
},
},
},
Media Tracks (Alpha)
This feature is in alpha. APIs may change before beta.
Room's media layer handles audio, video, and screen-share state. The actual WebRTC negotiation happens through Cloudflare Realtime or your own SFU; room.media manages the control plane (who is publishing, mute state, device selection).
Basic Voice Chat
const room = client.room('voice-chat', 'channel-1');
await room.join();
// Enable microphone
await room.media.audio.enable();
// Attach incoming audio tracks
room.media.onTrack((track, member) => {
if (track.kind === 'audio') {
const audio = new Audio();
audio.srcObject = new MediaStream([track]);
audio.play();
}
});
// Mute/unmute toggle
toggleMuteButton.addEventListener('click', async () => {
const isMuted = !room.media.audio.isMuted();
await room.media.audio.setMuted(isMuted);
updateMuteIcon(isMuted);
});
Video with Screen Share
// Enable camera
await room.media.video.enable({ deviceId: preferredCamera });
// Start screen share
await room.media.screen.start();
// Render all tracks
room.media.onTrack((track, member) => {
const el = track.kind === 'video'
? createVideoElement(member.memberId, track)
: createAudioElement(member.memberId, track);
mediaContainer.appendChild(el);
});
room.media.onTrackRemoved((track, member) => {
removeMediaElement(member.memberId, track.kind);
});
// Monitor media state changes (mute, device switch)
room.media.onStateChange((member, state) => {
updateMediaIndicator(member.memberId, {
audioMuted: state.audioMuted,
videoEnabled: state.videoEnabled,
screenSharing: state.screenSharing,
});
});
Device Selection
// Switch audio input
await room.media.devices.switch({ audioInputId: 'new-mic-id' });
// List available devices and let user choose
room.media.onDeviceChange((devices) => {
renderDeviceSelector(devices);
});
Admin Moderation
Moderators can control other members' media through room.admin:
// Mute a disruptive user (server-validated)
await room.admin.mute(memberId);
// Disable a user's video
await room.admin.disableVideo(memberId);
// Stop a user's screen share
await room.admin.stopScreenShare(memberId);
The server validates that the caller has the required role before executing these operations. Configure access via access.media.control in the room config.
Related Docs
- State
- Members (Presence)
- Signals (Broadcast)
- Media (Voice/Video)
- Server Hooks and Actions
- Advanced -- persistence, security model, cost