Skip to content

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.

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

ModeDescription
autoServer pairs clients automatically (FIFO) once groupSize is reached. The default.
claimClients see the member list and claim a partner; the first claim wins.
mutualBoth clients must claim each other before the match fires.
proposeOne client proposes; the other accepts or rejects.
delegatedA 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.

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

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

ts
await client.topics.subscribe("cursor", (frame) => {
  console.log(frame.payload);
});
python
await client.subscribe("cursor", lambda frame: print(frame.payload))
swift
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.

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

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

OperationDescription
replaceReplace the entire value
mergeShallow-merge an object into the existing value
set.addAdd elements to a set
set.removeRemove elements from a set
list.addAppend elements to a list
list.removeRemove elements from a list
counter.addIncrement a numeric counter
deleteDelete the key

Scopes

  • session — shared across all clients in the session
  • self — private to the current client (visible only to you)

Optimistic Concurrency

Use expectedVersion to prevent conflicting writes:

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

FieldTypeDescription
vnumberProtocol version (2), optional after handshake
idstringUnique frame identifier
resourcestringTarget resource (e.g. topic, session, state)
methodstringOperation (e.g. publish, join, set)
kindstringMessage role: "request", "response", or "event"
tsnumberTimestamp (optional)
sessionstringSession name (optional)
fromstringSender's client ID (optional)
tostring | string[]Recipient(s) (optional)
topicstringTopic name (optional)
replyTostringID of the request this responds to (optional)
deliveryDeliveryOptionsDelivery configuration (optional)
prioritystring"low", "normal", "high", or "critical" (optional)
ttlnumberTime-to-live in ms (optional)
metaRecord<string, unknown>Application-specific metadata (optional)

payload — the application data (optional).

Kind Semantics

The kind field describes the role of each frame:

KindDescriptionExamples
requestA method invocation that expects a responsejoin, subscribe, set, get
responseA reply to a request (matched via replyTo)Join confirmation, get result
eventAn unsolicited notification — fire-and-forgetpublish, presence.set, state.changed

header.meta Extensibility

The meta field allows applications to attach custom metadata to any frame without conflicting with protocol fields:

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

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

ts
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
});
ReliabilityBehavior
reliableGuaranteed delivery via WebSocket (default)
unreliableBest-effort, may be dropped — ideal for high-frequency updates like cursor positions
latestOnly 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
StateDescription
disconnectedNot connected to the server
connectingWebSocket connection in progress
connectedConnected and ready to send/receive
reconnectingConnection lost, attempting to reconnect
ts
client.connectionState$.subscribe((state) => {
  console.log("Connection state:", state);
});
python
client.connection_state.subscribe(
    lambda state: print("Connection state:", state)
)
swift
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.