Relay SDK Reference
Real-time multiplayer messaging and WebRTC P2P signaling for Creator-tier apps. vibes.relay is auto-injected on run.itjustvibes.com. No imports needed.
Overview
Creator-tier only. Free accounts cannot host a relay room. Calling vibes.relay.join() from an app whose owner is not a Creator subscriber throws {code: 'TIER_REQUIRED'}.
| Need | Use |
|---|---|
| Named-event broadcast (game ticks, moves, scores) | vibes.relay.send() — Broadcast |
| Peer-to-peer heavy traffic (streaming, high-frequency) | RTCDataChannel (P2P direct) |
| Peer roster (who is online in the room) | vibes.relay.on('join'/'leave') — Presence |
| WebRTC handshake (offer/answer/ICE exchange) | vibes.relay.signal() |
| Per-user persistence | vibes.save() / vibes.load() |
| Widget-level shared state (Free-compatible) | vibes.shared — NOT vibes.relay |
Critical rule: Use Broadcast (relay.send) for ALL named events and game data. Presence is used internally for the peer roster only — it caps at ~20 msg/s and is NOT suitable for carrying game tick data.
Tier & Quotas
All limits are enforced by the relay join gate (server-side). The per-message send rate is a client-side UX throttle — the relay broker does not enforce per-message rate. Size your traffic against the hard server caps.
| Limit | Value | Enforced by |
|---|---|---|
| Concurrent relay rooms per user | 5 | Join gate (server) |
| Connections per room | 10 | Join gate (server) |
| Payload size per message | 8 KB | Join gate (server) + SDK pre-check |
| Per-connection send rate | 5 msg/s | SDK client-side UX throttle only |
Methods
vibes.relay.join(roomName)
Join a relay room. Must be called before any other vibes.relay method. POSTs to /api/relay/join, creates a private Supabase Realtime channel, wires Broadcast (named events + signal routing) and Presence (peer roster), and starts a 15s heartbeat.
vibes.relay.join(roomName: string): Promise<{ roomName: string; peerId: string }>Parameters
| Name | Type | Description | |
|---|---|---|---|
roomName | string | req | Relay room identifier. Scoped to your app. Default is "main" if omitted. |
Returns
Promise<{ roomName: string; peerId: string }>— Resolves with the room name and your stable peer ID for this SDK session.Errors
| Error Code | Description |
|---|---|
TIER_REQUIRED | App owner does not have an active Creator subscription. |
ROOM_FULL | The room has reached its connection limit (10 peers). |
MAX_ROOMS_REACHED | You are already in the maximum number of concurrent rooms (5). |
NO_CLIENT | Supabase Realtime client is unavailable. |
JOIN_FAILED | Server returned an error during join. |
vibes.onReady(async () => {
try {
const { peerId } = await vibes.relay.join('game-room');
console.log('joined as', peerId);
} catch (err) {
if (err.code === 'TIER_REQUIRED') {
console.error('Relay requires a Creator subscription');
} else if (err.code === 'ROOM_FULL') {
console.error('Room is full — try again later');
}
}
});vibes.relay.send(eventName, data)
Broadcast a named event to all other peers in the room via Supabase Realtime Broadcast. Use this for ALL game data and named events. Does not send to the sender (self: false). Client-side rate throttle: 5 msg/s (UX guard — warns in console if exceeded, not a hard server gate).
vibes.relay.send(eventName: string, data: unknown): voidParameters
| Name | Type | Description | |
|---|---|---|---|
eventName | string | req | Event name that listeners registered with on() will receive. |
data | unknown | req | JSON-serializable payload. Must be under 8 KB (server-enforced). |
Returns
void— Returns nothing. Drops the message with a console.warn if not joined, payload too large, or rate limit exceeded.// Send named events to all peers
vibes.relay.send('move', { x: 42, y: 100 });
vibes.relay.send('score', { player: myPeerId, points: 10 });
vibes.relay.send('state', { phase: 'countdown', tick: 42 });vibes.relay.on(eventName, callback)
Register a callback for named relay events from other peers. Also handles the special lifecycle events: "join" (peer roster join), "leave" (peer roster leave), and "reconnect" (relay channel reconnected after disconnect). Call before or after join — callbacks are buffered.
vibes.relay.on(eventName: string, callback: Function): voidParameters
| Name | Type | Description | |
|---|---|---|---|
eventName | string | req | Event name to listen for. Special names: "join", "leave", "reconnect". |
callback | Function | req | For game events: callback(data, rawPayload). For "join"/"leave": callback(peerId). For "reconnect": callback(). |
Returns
void— No return value.// Game event
vibes.relay.on('move', (data) => {
updatePosition(data.x, data.y);
});
// Peer roster
vibes.relay.on('join', (peerId) => {
console.log('peer joined:', peerId);
addPlayerToUI(peerId);
});
vibes.relay.on('leave', (peerId) => {
console.log('peer left:', peerId);
removePlayerFromUI(peerId);
});
// Reconnect notification
vibes.relay.on('reconnect', () => {
console.log('relay reconnected — re-sync state');
});vibes.relay.signal(peerId, payload)
Send a WebRTC signaling payload (offer, answer, or ICE candidate) to a specific peer via the "__signal__" reserved Broadcast event. For the WebRTC handshake ONLY — never send game data through signal(). The server delivers the signal only to the addressed peer (not broadcast to all).
vibes.relay.signal(peerId: string, payload: unknown): voidParameters
| Name | Type | Description | |
|---|---|---|---|
peerId | string | req | Peer ID of the intended recipient. Obtain from relay.getPeers() or the "join" event callback. |
payload | unknown | req | WebRTC signaling payload: offer SDP, answer SDP, or ICE candidate object. |
Returns
void— Returns nothing. Warns in console if not joined.// Initiator sends offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
vibes.relay.signal(remotePeerId, { type: 'offer', sdp: offer.sdp });
// Responder sends answer
vibes.relay.signal(remotePeerId, { type: 'answer', sdp: answer.sdp });
// Both sides exchange ICE candidates
pc.onicecandidate = (e) => {
if (e.candidate) {
vibes.relay.signal(remotePeerId, { type: 'ice', candidate: e.candidate });
}
};vibes.relay.onSignal(callback)
Register a callback for incoming WebRTC signals addressed to this peer. The relay routes "__signal__" events exclusively to the addressed peer — other peers in the room do not receive them. Use this to handle offer/answer/ICE payloads from the remote peer.
vibes.relay.onSignal(callback: (fromPeerId: string, payload: unknown) => void): voidParameters
| Name | Type | Description | |
|---|---|---|---|
callback | (fromPeerId: string, payload: unknown) => void | req | Receives the sending peer ID and the signal payload. |
Returns
void— No return value.vibes.relay.onSignal(async (from, payload) => {
if (payload.type === 'offer') {
await pc.setRemoteDescription({ type: 'offer', sdp: payload.sdp });
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
vibes.relay.signal(from, { type: 'answer', sdp: answer.sdp });
} else if (payload.type === 'answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: payload.sdp });
} else if (payload.type === 'ice') {
await pc.addIceCandidate(payload.candidate);
}
});vibes.relay.getIceServers()
Returns the ICE server list (STUN/TURN) for RTCPeerConnection configuration. Cached from the join response — returns immediately if already joined. Fetches from /api/relay/ice-servers if called before join.
vibes.relay.getIceServers(): Promise<RTCIceServer[]>Returns
Promise<RTCIceServer[]>— Array of RTCIceServer objects for use in new RTCPeerConnection({ iceServers }).const iceServers = await vibes.relay.getIceServers();
const pc = new RTCPeerConnection({ iceServers });vibes.relay.getPeers()
Returns a synchronous snapshot of the current peer roster from Presence state. Presence is used ONLY for the roster — game data flows through Broadcast (relay.send). Returns an empty array if not joined.
vibes.relay.getPeers(): Array<{ peerId: string; joinedAt: number }>Returns
Array<{ peerId: string; joinedAt: number }>— Snapshot of the current peer roster. Synchronous — no await needed.const peers = vibes.relay.getPeers();
console.log('peers in room:', peers.length);
// [{ peerId: 'peer-abc-...', joinedAt: 1718000000000 }, ...]vibes.relay.leave(roomName?)
Unsubscribe from a relay room, clear the 15s heartbeat, and notify the server via POST /api/relay/leave. If roomName is omitted, leaves the first joined room. Safe to call even if not joined.
vibes.relay.leave(roomName?: string): voidParameters
| Name | Type | Description | |
|---|---|---|---|
roomName | string | opt | Room to leave. Omit to leave the first joined room. |
Returns
void— No return value. Does not throw.// Leave a specific room
vibes.relay.leave('game-room');
// Leave the most recently joined room
vibes.relay.leave();
// Cleanup on page unload (SDK does this automatically via beforeunload)
window.addEventListener('beforeunload', () => {
vibes.relay.leave();
});Error Codes
| Error Code | Description |
|---|---|
TIER_REQUIRED | App owner does not have an active Creator subscription. Relay is a Creator-only feature. |
ROOM_FULL | The room has reached its connection cap (10 peers per room). |
MAX_ROOMS_REACHED | Per-user concurrent room limit reached (5 rooms per user). |
NO_CLIENT | Supabase Realtime client unavailable — SDK could not initialize. |
JOIN_FAILED | Server returned an error during join (check appId, room name validity). |
NETWORK_ERROR | Network failure during the join request. |
AI Prompt
Copy this prompt into your AI chat session to teach it about the vibes.relay API. Paste it before describing your multiplayer app and your AI will use the relay surface correctly from the start — including the Broadcast-not-Presence rule, tier requirement, and quota limits.
## Vibes Relay SDK Reference (App-Tier / Creator)
### Tier Requirement
vibes.relay is a **Creator-tier feature**. Free accounts cannot host a relay room.
If you call vibes.relay.join() from an app whose owner does not have an active Creator
subscription, the SDK throws { code: 'TIER_REQUIRED' }.
### When to Use relay vs shared
| Need | Use |
|------|-----|
| Named-event broadcast (game ticks, player moves, scores) | vibes.relay.send() — Broadcast |
| Peer-to-peer heavy traffic (streaming, high-frequency game data) | RTCDataChannel (P2P direct) |
| Peer roster (who is online in the room) | vibes.relay on('join'/'leave') — Presence |
| WebRTC handshake (offer/answer/ICE exchange) | vibes.relay.signal() |
| Per-user persistence | vibes.save() / vibes.load() |
| Widget-level shared state (Free-compatible) | vibes.shared — NOT vibes.relay |
**Critical rule:** Use Broadcast (relay.send) for ALL named events and game data.
Presence is used internally for the peer roster only — it caps at ~20 msg/s and is
NOT suitable for carrying game tick data. All game data must flow through relay.send
(Broadcast) or an RTCDataChannel, never through Presence.
### Tier-Scoped Quotas (Creator plan)
| Limit | Value | Enforced by |
|-------|-------|-------------|
| Concurrent relay rooms per user | 5 | join gate (server) |
| Connections per room | 10 | join gate (server) |
| Payload size per message | 8 KB | join gate (server) + SDK pre-check |
| Per-connection send rate | 5 msg/s | SDK client-side UX throttle only |
Note: the per-message rate (5 msg/s) is a **client-side UX throttle** — the relay
broker does NOT enforce per-message rate server-side. The hard server gates are the
connection cap, room cap, and payload size limit. Size your traffic accordingly.
### Setup
vibes.relay is available as window.vibes.relay (or just vibes.relay) in every app
running on run.itjustvibes.com. No imports needed — the platform injects it.
```js
vibes.onReady(async () => {
const { peerId } = await vibes.relay.join('my-room');
console.log('joined as', peerId);
});
```
### vibes.relay.join(roomName)
Join a relay room. Must be called before any other relay method.
```js
const { roomName, peerId } = await vibes.relay.join('game-room');
// peerId is your stable peer ID for this SDK session
```
- Returns Promise<{ roomName: string, peerId: string }>
- Throws { code: 'TIER_REQUIRED' } if owner is not a Creator subscriber
- Throws { code: 'ROOM_FULL' } if connections per room limit is reached
- Throws { code: 'MAX_ROOMS_REACHED' } if per-user room limit is exceeded
- Throws { code: 'NO_CLIENT' } if Supabase Realtime is unavailable
### vibes.relay.send(eventName, data)
Broadcast a named event to all other peers in the room.
```js
vibes.relay.send('move', { x: 42, y: 100 });
vibes.relay.send('score', { player: peerId, points: 10 });
```
- data must be JSON-serializable and under 8 KB
- Client-side rate throttle: 5 msg/s per connection (UX guard — warns in console if exceeded)
- Does NOT send to the sender (self: false)
- Throws nothing — drops silently with console.warn if not joined or limits exceeded
### vibes.relay.on(eventName, callback)
Listen for incoming events from other peers.
```js
// Named game event
vibes.relay.on('move', (data) => {
updatePosition(data.x, data.y);
});
// Peer roster events
vibes.relay.on('join', (peerId) => {
console.log('peer joined:', peerId);
});
vibes.relay.on('leave', (peerId) => {
console.log('peer left:', peerId);
});
// Reconnect notification
vibes.relay.on('reconnect', () => {
console.log('relay reconnected');
});
```
- Special event names: 'join', 'leave', 'reconnect' (roster/lifecycle)
- All other event names are game/app events forwarded from relay.send()
### vibes.relay.signal(peerId, payload)
Send a WebRTC signaling payload (offer, answer, or ICE candidate) to a specific peer.
This is for the WebRTC handshake ONLY — never send game data through signal().
```js
// Initiator sends offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
vibes.relay.signal(remotePeerId, { type: 'offer', sdp: offer.sdp });
// Responder sends answer
vibes.relay.signal(remotePeerId, { type: 'answer', sdp: answer.sdp });
// Both sides send ICE candidates
pc.onicecandidate = (e) => {
if (e.candidate) vibes.relay.signal(remotePeerId, { type: 'ice', candidate: e.candidate });
};
```
- payload is any JSON-serializable object
- Delivered ONLY to the addressed peer (server routes on peerId — not broadcast to all)
### vibes.relay.onSignal(callback)
Register a callback for incoming WebRTC signals addressed to this peer.
```js
vibes.relay.onSignal((fromPeerId, payload) => {
if (payload.type === 'offer') {
// handle WebRTC offer
} else if (payload.type === 'answer') {
// handle WebRTC answer
} else if (payload.type === 'ice') {
pc.addIceCandidate(payload.candidate);
}
});
```
- callback(fromPeerId: string, payload: any)
### vibes.relay.getIceServers()
Returns the ICE server list (STUN/TURN) for RTCPeerConnection configuration.
Cached from the join response; fetches from the server if called before join.
```js
const iceServers = await vibes.relay.getIceServers();
const pc = new RTCPeerConnection({ iceServers });
```
- Returns Promise<Array<RTCIceServer>>
### vibes.relay.getPeers()
Returns a snapshot of the current peer roster (Presence state).
```js
const peers = vibes.relay.getPeers();
// [{ peerId: 'peer-abc123-...', joinedAt: 1718000000000 }, ...]
```
- Returns Array<{ peerId: string, joinedAt: number, ... }>
- Synchronous — no await needed
- Returns [] if not joined
### vibes.relay.leave(roomName)
Unsubscribe from a relay room, clear the heartbeat, and notify the server.
```js
vibes.relay.leave('game-room');
// or leave the most recently joined room:
vibes.relay.leave();
```
- If roomName is omitted, leaves the first joined room
- Does NOT throw — safe to call even if not joined
### Full P2P WebRTC Example
```js
vibes.onReady(async () => {
const { peerId: myId } = await vibes.relay.join('battle-room');
const iceServers = await vibes.relay.getIceServers();
let pc = null;
// Peer roster: elect the initiator lexicographically
vibes.relay.on('join', async (remotePeerId) => {
const isInitiator = myId < remotePeerId;
pc = new RTCPeerConnection({ iceServers });
const dc = isInitiator ? pc.createDataChannel('game') : null;
pc.onicecandidate = (e) => {
if (e.candidate) vibes.relay.signal(remotePeerId, { type: 'ice', candidate: e.candidate });
};
if (dc) {
dc.onmessage = (e) => console.log('p2p message:', e.data);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
vibes.relay.signal(remotePeerId, { type: 'offer', sdp: offer.sdp });
} else {
pc.ondatachannel = (e) => {
e.channel.onmessage = (m) => console.log('p2p message:', m.data);
};
}
});
vibes.relay.onSignal(async (from, payload) => {
if (payload.type === 'offer') {
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
vibes.relay.signal(from, { type: 'answer', sdp: answer.sdp });
} else if (payload.type === 'answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: payload.sdp });
} else if (payload.type === 'ice') {
await pc.addIceCandidate(payload.candidate);
}
});
});
```
### Rules
1. **Call vibes.relay.join() once per room at startup** — before send, on, signal, or getPeers.
2. **Use relay.send() for game data** — not Presence, not relay.signal().
3. **Use relay.signal() for WebRTC handshake only** — offer/answer/ICE exchange, not game ticks.
4. **For high-frequency or large game data** — send over RTCDataChannel once P2P is connected.
5. **Await all relay methods that return Promises** (join, getIceServers).
6. **Wrap relay.join() in try/catch** — TIER_REQUIRED and ROOM_FULL are expected error codes.
7. **Do NOT use localStorage, sessionStorage, or window.storage** — use vibes.save/vibes.load for persistence.