LIVE
Creator tierApp-tier only

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'}.

NeedUse
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 persistencevibes.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.

LimitValueEnforced by
Concurrent relay rooms per user5Join gate (server)
Connections per room10Join gate (server)
Payload size per message8 KBJoin gate (server) + SDK pre-check
Per-connection send rate5 msg/sSDK 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

NameType Description
roomNamestringreqRelay 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 CodeDescription
TIER_REQUIREDApp owner does not have an active Creator subscription.
ROOM_FULLThe room has reached its connection limit (10 peers).
MAX_ROOMS_REACHEDYou are already in the maximum number of concurrent rooms (5).
NO_CLIENTSupabase Realtime client is unavailable.
JOIN_FAILEDServer returned an error during join.
JavaScript
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): void

Parameters

NameType Description
eventNamestringreqEvent name that listeners registered with on() will receive.
dataunknownreqJSON-serializable payload. Must be under 8 KB (server-enforced).

Returns

voidReturns nothing. Drops the message with a console.warn if not joined, payload too large, or rate limit exceeded.
JavaScript
// 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): void

Parameters

NameType Description
eventNamestringreqEvent name to listen for. Special names: "join", "leave", "reconnect".
callbackFunctionreqFor game events: callback(data, rawPayload). For "join"/"leave": callback(peerId). For "reconnect": callback().

Returns

voidNo return value.
JavaScript
// 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): void

Parameters

NameType Description
peerIdstringreqPeer ID of the intended recipient. Obtain from relay.getPeers() or the "join" event callback.
payloadunknownreqWebRTC signaling payload: offer SDP, answer SDP, or ICE candidate object.

Returns

voidReturns nothing. Warns in console if not joined.
JavaScript
// 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): void

Parameters

NameType Description
callback(fromPeerId: string, payload: unknown) => voidreqReceives the sending peer ID and the signal payload.

Returns

voidNo return value.
JavaScript
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 }).
JavaScript
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.
JavaScript
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): void

Parameters

NameType Description
roomNamestringoptRoom to leave. Omit to leave the first joined room.

Returns

voidNo return value. Does not throw.
JavaScript
// 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 CodeDescription
TIER_REQUIREDApp owner does not have an active Creator subscription. Relay is a Creator-only feature.
ROOM_FULLThe room has reached its connection cap (10 peers per room).
MAX_ROOMS_REACHEDPer-user concurrent room limit reached (5 rooms per user).
NO_CLIENTSupabase Realtime client unavailable — SDK could not initialize.
JOIN_FAILEDServer returned an error during join (check appId, room name validity).
NETWORK_ERRORNetwork 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.

Relay SDK Prompt— Creator-tier relay API reference
## 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.
FEEDDISCOVER
Start typing to search