Skip to content

Common Workflows

Joining and Leaving Sessions

ts
// 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();
python
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()
swift
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.

ts
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 });
python
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.

ts
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);
}
python
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$.

ts
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.

ts
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,
});
python
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.

ts
// 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");
python
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")
swift
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.

ts
// 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);
    }
  }
});
python
# 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
])
swift
// 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

ts
// 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;
});
python
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)
)
swift
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

ts
// 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

ts
// Append to a log
await client.state.set({
  key: "chat-history",
  scope: "session",
  op: "list.add",
  value: [{ user: "Alice", text: "Hello!" }],
});

Merge Pattern

ts
// 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.

ts
// 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" });
python
# 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"})
swift
// 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.

ts
// 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();
  });
});
python
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)
)
swift
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:

ts
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:

ts
client.topics.publish("slider", { value: 0.75 }, {
  delivery: {
    reliability: "latest",
  },
});