Common Workflows
Joining and Leaving Sessions
// Join with options
const response = await client.join("art-room", {
name: "Alice",
role: "performer",
meta: { instrument: "piano" },
create: true, // create session if it doesn't exist; omitted, it's join-only
});
// The response contains the list of clients already in the session
console.log("Clients in session:", response.payload.clients);
// React to clients joining and leaving
client.clients$.subscribe((clients) => {
console.log("Current clients:", clients.map((c) => c.name));
});
// Leave when done
await client.leave();from starfish import JoinOptions
response = await client.join("art-room", JoinOptions(
name="Alice",
role="performer",
meta={"instrument": "piano"},
create=True,
))
print("Clients in session:", response.payload.get("clients"))
client.clients.subscribe(
lambda clients: print("Current clients:", [c.name for c in clients])
)
await client.leave()let response = try await client.join(session: "art-room", options: JoinOptions(
name: "Alice",
role: "performer",
meta: ["instrument": "piano"],
create: true
))
Task {
for await clients in client.clients {
print("Current clients:", clients.map { $0.name ?? $0.id })
}
}
try client.leave()Pool Matchmaking
Pools pair clients into a server-created session. A client enters a pool, waits to be matched, and then joins the returned session — matchmaking precedes session membership, so there's no lobby to join first. See Core Concepts for the mode overview and the Pool reference for the full API.
The examples below use the ad-hoc form (the client supplies the pool config), which works in an implicit Project. In production you typically declare the config as a matchmaking policy and join by name — see Matchmaking as a Policy.
Auto-pairing
The simplest flow: enter an auto pool, await the match, join the session.
client.pool.matched$.subscribe(async ({ session }) => {
await client.join(session);
// ...you're now in the matched session with your partner.
});
await client.pool.enter("duets", { groupSize: 2, mode: "auto", create: true });from starfish import PoolEnterOptions
async def on_match(result):
await client.join(result.session)
client.pool_matched.subscribe(on_match)
await client.pool_enter(PoolEnterOptions(pool="duets", group_size=2, mode="auto"))Claiming a partner
In claim mode the member list is visible. Observe it, then claim a specific member; the match fires immediately and both clients receive matched.
client.pool.members$.subscribe((members) => {
renderCandidates(members); // show who's waiting
});
client.pool.matched$.subscribe(async ({ session }) => {
await client.join(session);
});
await client.pool.enter("arena", { groupSize: 2, mode: "claim", create: true });
// Later, when the user picks someone:
function onPick(targetId: string) {
client.pool.claim("arena", targetId);
}from starfish import PoolEnterOptions
client.pool_members("arena").subscribe(lambda members: render_candidates(members))
async def on_match(result):
await client.join(result.session)
client.pool_matched.subscribe(on_match)
await client.pool_enter(PoolEnterOptions(pool="arena", group_size=2, mode="claim"))
# Later, when the user picks someone:
async def on_pick(target_id):
await client.pool_claim("arena", target_id)Proposing (accept / reject)
In propose mode, one side proposes with claim and the other accepts or rejects. In TypeScript, incoming proposals arrive on pool.proposal$.
client.pool.proposal$.subscribe(({ from }) => {
if (wantToPairWith(from)) {
client.pool.accept("arena", from);
} else {
client.pool.reject("arena", from);
}
});
client.pool.matched$.subscribe(async ({ session }) => {
await client.join(session);
});
await client.pool.enter("arena", { groupSize: 2, mode: "propose", create: true });
// Propose to a specific member:
client.pool.claim("arena", someMemberId);Delegated matchmaker
In delegated mode a trusted client enters with role: "matchmaker", watches members arrive, and forms groups explicitly with assign(). The matchmaker is not consumed by matching and can assign repeatedly.
client.pool.members$.subscribe((members) => {
if (members.length >= 4) {
const ids = members.map((m) => m.id);
// Two groups of two.
client.pool.assign("teams", [
[ids[0], ids[1]],
[ids[2], ids[3]],
]);
}
});
await client.pool.enter("teams", {
groupSize: 2,
mode: "delegated",
role: "matchmaker",
create: true,
});from starfish import PoolEnterOptions
async def on_members(members):
if len(members) >= 4:
ids = [m.id for m in members]
await client.pool_assign("teams", [[ids[0], ids[1]], [ids[2], ids[3]]])
client.pool_members("teams").subscribe(on_members)
await client.pool_enter(PoolEnterOptions(
pool="teams", group_size=2, mode="delegated", role="matchmaker",
))Regular members in a delegated pool just enter and wait for matched — only the matchmaker calls assign().
Pub/Sub with Topics
Topics are the primary way to broadcast messages to interested clients.
// Set up listener before subscribing to avoid missing messages
const unsub = client.topics.topic$("drawing").subscribe((frame) => {
const { x, y, color } = frame.payload;
drawPoint(x, y, color);
});
// Tell the server you want to receive "drawing" messages
await client.topics.subscribe("drawing");
// Publish drawing events
canvas.addEventListener("pointermove", (e) => {
client.topics.publish("drawing", {
x: e.offsetX,
y: e.offsetY,
color: currentColor,
});
});
// Clean up
unsub();
await client.topics.unsubscribe("drawing");unsub = client.topic_stream("drawing").subscribe(
lambda frame: draw_point(
frame.payload["x"],
frame.payload["y"],
frame.payload["color"],
)
)
await client.subscribe("drawing")
# Publish drawing events
await client.publish("drawing", {
"x": x,
"y": y,
"color": current_color,
})
# Clean up
unsub()
await client.unsubscribe("drawing")let drawingTask = Task {
for await frame in client.topics.messages(forTopic: "drawing") {
let x = frame.payloadInt("x") ?? 0
let y = frame.payloadInt("y") ?? 0
drawPoint(x: x, y: y)
}
}
try await client.topics.subscribe(topic: "drawing")
// Publish drawing events
try client.topics.publish(topic: "drawing", payload: [
"x": x, "y": y, "color": currentColor
])
// Clean up
drawingTask.cancel()
try client.topics.unsubscribe(topic: "drawing")Presence Tracking
Presence is ideal for state that updates frequently and where only the latest value matters — cursor positions, active tool selections, typing indicators.
// Share your cursor position
document.addEventListener("mousemove", (e) => {
client.presence.set({
cursor: { x: e.clientX, y: e.clientY },
tool: "brush",
});
});
// Render other clients' cursors
client.presence$.subscribe((presenceMap) => {
for (const [clientId, data] of presenceMap) {
if (clientId !== client.clientId) {
renderCursor(clientId, data.cursor);
}
}
});# Share your cursor position
client.presence_set({
"cursor": {"x": x, "y": y},
"tool": "brush",
})
# Render other clients' cursors
client.presence.subscribe(lambda presence: [
render_cursor(cid, data.get("cursor"))
for cid, data in presence.items()
if cid != client.client_id
])// Share your cursor position
try client.presence.set([
"cursor": ["x": x, "y": y],
"tool": "brush"
])
// Render other clients' cursors
Task {
for await presenceMap in client.presence.presenceUpdates {
for (clientId, data) in presenceMap {
if clientId != client.clientId {
renderCursor(clientId: clientId, data: data)
}
}
}
}Shared State
Use shared state for values that need to persist within the session and support structured updates.
Counter Pattern
// Increment a shared counter
await client.state.set({
key: "likes",
scope: "session",
op: "counter.add",
value: 1,
});
// Watch counter changes
client.state.keyChanges$("likes").subscribe((result) => {
document.getElementById("likes").textContent = result.value;
});from starfish import SetOptions
await client.state.set(SetOptions(
key="likes",
scope="session",
op="counter.add",
value=1,
))
client.state.key_stream("likes").subscribe(
lambda result: print("Likes:", result.value)
)try await client.state.set(SetOptions(
key: "likes",
scope: .session,
op: .counterAdd,
value: 1
))
Task {
for await result in client.state.changes(forKey: "likes") {
print("Likes:", result.value)
}
}Set Pattern
// Add a tag
await client.state.set({
key: "tags",
scope: "session",
op: "set.add",
value: ["creative-coding", "music"],
});
// Remove a tag
await client.state.set({
key: "tags",
scope: "session",
op: "set.remove",
value: ["music"],
});List Pattern
// Append to a log
await client.state.set({
key: "chat-history",
scope: "session",
op: "list.add",
value: [{ user: "Alice", text: "Hello!" }],
});Merge Pattern
// Partially update an object (shallow merge)
await client.state.set({
key: "settings",
scope: "session",
op: "merge",
value: { volume: 0.8 },
});Direct Messaging
Send messages to specific clients instead of broadcasting to a topic.
// Send to one client
client.messaging.send(peerId, { type: "offer", data: myOffer });
// Send to multiple clients
client.messaging.send([peerId1, peerId2], { type: "sync", data: snapshot });
// Broadcast to everyone in the session
client.messaging.broadcast({ type: "announcement", text: "Starting in 10s" });# Send to one client
await client.send(peer_id, {"type": "offer", "data": my_offer})
# Send to multiple clients
await client.send([peer_id1, peer_id2], {"type": "sync", "data": snapshot})
# Broadcast to everyone in the session
await client.broadcast({"type": "announcement", "text": "Starting in 10s"})// Send to one client
try client.messaging.send(to: peerId, payload: ["type": "offer", "data": myOffer])
// Send to multiple clients
try client.messaging.send(to: .multiple([peerId1, peerId2]), payload: ["type": "sync"])
// Broadcast to everyone in the session
try client.messaging.broadcast(payload: ["type": "announcement", "text": "Starting in 10s"])Synchronized Timing
Use the clock to coordinate events across clients at the same moment.
// Sync clocks first
await client.clock.sync();
// Schedule something 5 seconds from now (in server time)
const targetTime = client.clock.now() + 5000;
// Share the target time with all clients
client.topics.publish("sync", { action: "flash", at: targetTime });
// Each client schedules the action at the same server time
client.topics.topic$("sync").subscribe((frame) => {
client.clock.runAt(frame.payload.at, () => {
triggerFlash();
});
});await client.clock.sync()
target_time = client.clock.now() + 5000
await client.publish("sync", {"action": "flash", "at": target_time})
client.topic_stream("sync").subscribe(
lambda frame: client.at(frame.payload["at"], trigger_flash)
)try await client.clock.sync()
let targetTime = client.clock.now() + 5000
try client.topics.publish(topic: "sync", payload: ["action": "flash", "at": targetTime])
Task {
for await frame in client.topics.messages(forTopic: "sync") {
if let at = frame.payloadInt("at") {
client.at(serverTime: at) {
triggerFlash()
}
}
}
}High-Frequency Updates with Delivery Options
For data that updates many times per second (cursor positions, sensor readings), use unreliable delivery to reduce latency:
client.topics.publish("sensor", { value: reading }, {
delivery: {
reliability: "unreliable",
ordering: "unordered",
},
});Or use "latest" delivery when only the most recent value matters — the transport may skip intermediate messages:
client.topics.publish("slider", { value: 0.75 }, {
delivery: {
reliability: "latest",
},
});