Core Concepts
Projects
A Project is the isolation scope every connection is bound to at the handshake: all session and pool names resolve within a Project, never globally. Omit it and you get the reserved "default" Project — the zero-config path that behaves as a single global keyspace. Provision a Project through the control plane and it also carries declared configuration (matchmaking policies, credential bindings). Projects are covered in depth on their own page — see Projects.
Sessions
A session is a named room that groups clients together. All communication — topics, presence, state, and direct messaging — happens within a session (and within the connection's Project). A client must join a session before using any of these features.
// Join a session, creating it if it doesn't exist (a bare join is join-only)
await client.join("my-session", { create: true });
// Join with identity metadata
await client.join("my-session", {
name: "Alice",
role: "performer",
meta: { instrument: "piano" },
});
// Track who's in the session
client.clients$.subscribe((clients) => {
console.log("All clients:", clients);
});
// Track peers (everyone except yourself)
client.peers$.subscribe((peers) => {
console.log("Peers:", peers);
});
// Leave the session
await client.leave();from starfish import JoinOptions
# Join a session, creating it if it doesn't exist (a bare join is join-only)
await client.join("my-session", JoinOptions(create=True))
# Join with identity metadata
await client.join("my-session", JoinOptions(
name="Alice",
role="performer",
meta={"instrument": "piano"},
))
# Track who's in the session
client.clients.subscribe(lambda clients: print("All clients:", clients))
# Track peers (everyone except yourself)
client.peers.subscribe(lambda peers: print("Peers:", peers))
# Leave the session
await client.leave()// Join a session, creating it if it doesn't exist (a bare join is join-only)
try await client.join(session: "my-session", options: JoinOptions(create: true))
// Join with identity metadata
try await client.join(session: "my-session", options: JoinOptions(
name: "Alice",
role: "performer",
meta: ["instrument": "piano"]
))
// Track who's in the session
Task {
for await clients in client.clients {
print("All clients:", clients)
}
}
// Track peers (everyone except yourself)
Task {
for await peers in client.peers {
print("Peers:", peers)
}
}
// Leave the session
try client.leave()Each client in a session has a ClientInfo containing their id, optional name, role, and meta.
Pools
A pool is a named matchmaking queue. Instead of knowing a session name up front, clients enter a pool and the server pairs them into a freshly created session. Pools decide which session a client joins; everything else (topics, presence, data) happens inside that session as usual. Pools operate over WebSocket only.
The mode is chosen when the pool is created and determines how matches form:
| Mode | Description |
|---|---|
auto | Server pairs clients automatically (FIFO) once groupSize is reached. The default. |
claim | Clients see the member list and claim a partner; the first claim wins. |
mutual | Both clients must claim each other before the match fires. |
propose | One client proposes; the other accepts or rejects. |
delegated | A matchmaker-role client forms groups explicitly with assign(). |
The common case is auto mode: enter the pool, wait for a match, then join the session the server hands back. Matchmaking precedes session membership — you don't join a lobby first — and matched clients are not auto-joined, which gives you a moment to show a "matched" screen or load assets before joining.
// React to being matched: join the session the server created.
client.pool.matched$.subscribe(async ({ session, peers }) => {
console.log("Matched with", peers.map((p) => p.id));
await client.join(session); // join the matched session
});
// Enter the pool. `create: true` opens it if it doesn't exist yet.
await client.pool.enter("duets", { groupSize: 2, mode: "auto", create: true });from starfish import PoolEnterOptions
# React to being matched: join the session the server created.
async def on_match(result):
print("Matched with", [p.id for p in result.peers])
await client.join(result.session)
client.pool_matched.subscribe(on_match)
# Enter the pool. create=True opens it if it doesn't exist yet.
await client.pool_enter(PoolEnterOptions(pool="duets", group_size=2, mode="auto", create=True))The example above is the ad-hoc form, where the client supplies the pool config — ideal for local development in an implicit Project. In production you typically move that config server-side as a matchmaking policy and have clients join by name with client.joinProject(project, { policy }), so the server owns the rules. See Matchmaking as a Policy.
In claim-based modes (claim, mutual, propose), the member list is visible, so you can present partners and let a client choose. Observe it with pool.members$ in TypeScript or pool_members(name) in Python. For a full walkthrough of each mode, see Pool Matchmaking; for the complete API, see the Pool reference.
Topics
Topics are named pub/sub channels within a session. Subscribe to a topic to receive messages, and publish to send messages to all subscribers.
// Subscribe to a topic
await client.topics.subscribe("cursor");
// Listen for messages
client.topics.topic$("cursor").subscribe((frame) => {
console.log(frame.header.from, frame.payload);
});
// Publish to a topic
client.topics.publish("cursor", { x: 100, y: 200 });
// Unsubscribe
await client.topics.unsubscribe("cursor");# Subscribe to a topic
await client.subscribe("cursor")
# Listen for messages
client.topic_stream("cursor").subscribe(
lambda frame: print(frame.header.from_id, frame.payload)
)
# Publish to a topic
await client.publish("cursor", {"x": 100, "y": 200})
# Unsubscribe
await client.unsubscribe("cursor")// Subscribe to a topic
try await client.topics.subscribe(topic: "cursor")
// Listen for messages
Task {
for await frame in client.topics.messages(forTopic: "cursor") {
print(frame.header.from, frame.payload)
}
}
// Publish to a topic
try client.topics.publish(topic: "cursor", payload: ["x": 100, "y": 200])
// Unsubscribe
try client.topics.unsubscribe(topic: "cursor")You can also pass a callback directly to subscribe as a shorthand:
await client.topics.subscribe("cursor", (frame) => {
console.log(frame.payload);
});await client.subscribe("cursor", lambda frame: print(frame.payload))try await client.topics.subscribe(topic: "cursor") { frame in
print(frame.payload)
}Presence
Presence lets each client share ephemeral state — like cursor position, status, or tool selection — with all other clients in the session. Unlike topics, presence represents "current state" rather than a stream of events. Setting presence replaces your previous value.
// Set your presence
client.presence.set({ cursor: { x: 50, y: 75 }, tool: "brush" });
// Observe all presence data (Map of clientId → presence)
client.presence$.subscribe((presenceMap) => {
for (const [clientId, data] of presenceMap) {
console.log(clientId, data);
}
});# Set your presence
client.presence_set({"cursor": {"x": 50, "y": 75}, "tool": "brush"})
# Observe all presence data (dict of clientId → presence)
client.presence.subscribe(
lambda presence: [print(k, v) for k, v in presence.items()]
)// Set your presence
try client.presence.set(["cursor": ["x": 50, "y": 75], "tool": "brush"])
// Observe all presence data
Task {
for await presenceMap in client.presence.presenceUpdates {
for (clientId, data) in presenceMap {
print(clientId, data)
}
}
}Presence data is limited to 8 KB per client.
Shared State
Shared state provides persistent key-value storage within a session. Unlike presence, state persists for the lifetime of the session and supports structured operations for conflict-free updates.
// Set state
await client.state.set({
key: "score",
scope: "session",
op: "replace",
value: { points: 42 },
});
// Read state
const result = await client.state.get({ key: "score", scope: "session" });
console.log(result.value, result.version);
// Listen for changes to a specific key
client.state.keyChanges$("score").subscribe((result) => {
console.log("Score updated:", result.value);
});
// Listen for all state changes
client.state.changes$.subscribe((result) => {
console.log(result.key, result.value);
});from starfish import SetOptions
# Set state
await client.state.set(SetOptions(
key="score",
scope="session",
op="replace",
value={"points": 42},
))
# Read state
result = await client.state.get("score", scope="session")
print(result.value, result.version)
# Listen for changes to a specific key
client.state.key_stream("score").subscribe(
lambda result: print("Score updated:", result.value)
)
# Listen for all state changes
client.state.changed.subscribe(
lambda result: print(result.key, result.value)
)// Set state
let result = try await client.state.set(SetOptions(
key: "score",
scope: .session,
op: .replace,
value: ["points": 42]
))
// Read state
let current = try await client.state.get(key: "score", scope: .session)
print(current.value, current.version)
// Listen for changes to a specific key
Task {
for await result in client.state.changes(forKey: "score") {
print("Score updated:", result.value)
}
}
// Listen for all state changes
Task {
for await result in client.state.changes {
print(result.key, result.value)
}
}State Operations
The op field determines how the state is applied:
| Operation | Description |
|---|---|
replace | Replace the entire value |
merge | Shallow-merge an object into the existing value |
set.add | Add elements to a set |
set.remove | Remove elements from a set |
list.add | Append elements to a list |
list.remove | Remove elements from a list |
counter.add | Increment a numeric counter |
delete | Delete the key |
Scopes
session— shared across all clients in the sessionself— private to the current client (visible only to you)
Optimistic Concurrency
Use expectedVersion to prevent conflicting writes:
const current = await client.state.get({ key: "score", scope: "session" });
await client.state.set({
key: "score",
scope: "session",
op: "replace",
value: { points: current.value.points + 1 },
expectedVersion: current.version,
});current = await client.state.get("score", scope="session")
await client.state.set(SetOptions(
key="score",
scope="session",
op="replace",
value={"points": current.value["points"] + 1},
expected_version=current.version,
))let current = try await client.state.get(key: "score", scope: .session)
try await client.state.set(SetOptions(
key: "score",
scope: .session,
op: .replace,
value: ["points": (current.value?.intValue ?? 0) + 1],
expectedVersion: current.version
))If another client has modified the state since you read it, the set will fail, allowing you to re-read and retry.
Frames
A frame is the fundamental message unit in the Starfish protocol. Every message sent between clients and the server is a frame. Frames use an envelope structure with two top-level fields:
header — routing and protocol metadata:
| Field | Type | Description |
|---|---|---|
v | number | Protocol version (2), optional after handshake |
id | string | Unique frame identifier |
resource | string | Target resource (e.g. topic, session, state) |
method | string | Operation (e.g. publish, join, set) |
kind | string | Message role: "request", "response", or "event" |
ts | number | Timestamp (optional) |
session | string | Session name (optional) |
from | string | Sender's client ID (optional) |
to | string | string[] | Recipient(s) (optional) |
topic | string | Topic name (optional) |
replyTo | string | ID of the request this responds to (optional) |
delivery | DeliveryOptions | Delivery configuration (optional) |
priority | string | "low", "normal", "high", or "critical" (optional) |
ttl | number | Time-to-live in ms (optional) |
meta | Record<string, unknown> | Application-specific metadata (optional) |
payload — the application data (optional).
Kind Semantics
The kind field describes the role of each frame:
| Kind | Description | Examples |
|---|---|---|
request | A method invocation that expects a response | join, subscribe, set, get |
response | A reply to a request (matched via replyTo) | Join confirmation, get result |
event | An unsolicited notification — fire-and-forget | publish, presence.set, state.changed |
header.meta Extensibility
The meta field allows applications to attach custom metadata to any frame without conflicting with protocol fields:
client.topics.publish("updates", { text: "hello" }, {
meta: { batchId: "batch_42", priority_level: 5 },
});You can listen for all frames using the low-level event API:
// All frames
client.onFrame((frame) => {
console.log(frame.header.resource, frame.header.method, frame.payload);
});
// Filtered frames
client.events$({ resource: "topic", method: "message", topic: "cursor" }).subscribe((frame) => {
console.log(frame.payload);
});# All frames
client.on(lambda frame: print(frame.header.resource, frame.header.method, frame.payload))
# Filtered frames
client.events(EventFilter(resource="topic", method="message", topic="cursor")).subscribe(
lambda frame: print(frame.payload)
)// All frames
client.on { frame in
print(frame.header.resource, frame.header.method, frame.payload as Any)
}
// Filtered frames
Task {
for await frame in client.events(filter: EventFilter(resource: "topic", method: "message", topic: "cursor")) {
print(frame.payload as Any)
}
}Delivery Options
When publishing or sending messages, you can control how they are delivered using HeaderOptions:
client.topics.publish("sensor-data", { value: 42 }, {
delivery: {
reliability: "unreliable", // "reliable" | "unreliable" | "latest"
ordering: "unordered", // "ordered" | "unordered"
preferTransport: "auto", // "ws" | "rtc" | "auto"
fallback: true, // fall back to WS if RTC unavailable
includeSelf: false, // receive your own messages
},
priority: "normal", // "low" | "normal" | "high" | "critical"
ttl: 5000, // time-to-live in ms
meta: {}, // application-specific metadata
});| Reliability | Behavior |
|---|---|
reliable | Guaranteed delivery via WebSocket (default) |
unreliable | Best-effort, may be dropped — ideal for high-frequency updates like cursor positions |
latest | Only the most recent value matters — intermediate messages may be skipped |
See Best Practices for guidance on choosing delivery options.
Connection Lifecycle
The client connection goes through four states:
disconnected → connecting → connected → reconnecting → connected
↓ ↓
disconnected disconnected| State | Description |
|---|---|
disconnected | Not connected to the server |
connecting | WebSocket connection in progress |
connected | Connected and ready to send/receive |
reconnecting | Connection lost, attempting to reconnect |
client.connectionState$.subscribe((state) => {
console.log("Connection state:", state);
});client.connection_state.subscribe(
lambda state: print("Connection state:", state)
)Task {
for await state in client.connectionState {
print("Connection state:", state)
}
}The client automatically reconnects with exponential backoff when the connection drops. See Configuration for reconnection options.