Skip to main content

Multi-Client

Multiple clients can connect to the same Idyllic system instance simultaneously. When one client modifies state through an action, the change broadcasts to all connected clients automatically. This enables collaborative features—shared documents, multiplayer experiences, live dashboards—without additional pub/sub infrastructure.

Default Behavior

The instance ID determines which Durable Object clients connect to. Same ID = same state:
// Client A
const { tasks, addTask } = useSystem<TaskManager>({ id: 'project-123' });

// Client B - same instance ID
const { tasks, addTask } = useSystem<TaskManager>({ id: 'project-123' });

// Both see the same tasks. When A adds one, B sees it immediately.
State changes broadcast to every connected client without configuration. When an action modifies a field, all clients see the update. When a stream produces content, all clients receive chunks.

Connection Tracking

Track connected clients with lifecycle methods:
export default class CollaborativeDoc extends AgenticSystem {
  @field content = '';
  @field activeUsers: string[] = [];

  onConnect(connection: Connection) {
    const userId = connection.metadata?.userId;
    if (userId) {
      this.activeUsers.push(userId);
    }
  }

  onDisconnect(connection: Connection) {
    const userId = connection.metadata?.userId;
    this.activeUsers = this.activeUsers.filter(id => id !== userId);
  }
}
Changes in lifecycle methods broadcast like any state modification:
function ActiveUsers() {
  const { activeUsers } = useSystem<CollaborativeDoc>({ id: 'doc-123' });

  return (
    <div className="avatars">
      {activeUsers.map(userId => <Avatar key={userId} userId={userId} />)}
    </div>
  );
}

Conflict Handling

Multiple clients calling actions that modify the same state get processed serially. The Durable Object executes one action at a time—last-write-wins:
// Client A at T=100ms
actions.setTitle('Version A');

// Client B at T=101ms
actions.setTitle('Version B');

// Result: 'Version B' (executed second)
For most applications this works well. For sophisticated needs like collaborative text editing, implement operational transforms or CRDTs within your actions.

Collaborative Patterns

Shared Cursors

Track cursor positions in shared state:
@field cursors: Record<string, { position: number; userId: string }> = {};

@action()
async updateCursor(userId: string, position: number) {
  this.cursors[userId] = { position, userId };
}

onDisconnect(connection: Connection) {
  delete this.cursors[connection.metadata?.userId];
}

Turn-Based Interactions

Validate actions come from the correct user:
@field currentTurn: string | null = null;
@field turnOrder: string[] = [];

@action()
async takeTurn(userId: string, move: Move) {
  if (this.currentTurn !== userId) {
    throw new Error('Not your turn');
  }
  await this.processMove(move);
  const nextIndex = (this.turnOrder.indexOf(userId) + 1) % this.turnOrder.length;
  this.currentTurn = this.turnOrder[nextIndex];
}

FAQ

Do all clients see the same state?

Yes. State lives on the server. All connected clients receive the same updates via WebSocket.

What happens with simultaneous actions?

The Durable Object processes actions serially. Both complete, but one finishes before the other starts. No race conditions.

How many clients can connect?

Hundreds work fine. For thousands, reduce update frequency and consider sharding.

What happens when a new client connects?

They immediately receive a full state snapshot, then incremental updates from there.

Can I have per-connection private state?

The state object is shared. For per-connection data, use connection metadata (doesn’t persist) or a map keyed by user ID in state (persists but visible to other clients).