Skip to main content

RTCIOBroadcastChannel

const chat = socket.createChannel("chat", { ordered: true });

A broadcast channel is a logical DataChannel shared with every connected peer (and any peer that joins later). Internally it's a Map<peerId, RTCIOChannel> — one per-peer DataChannel under the hood, fanned out by the broadcast wrapper.

Construction

You don't construct it directly. Call socket.createChannel(name, options) — the library returns either a fresh instance or the existing one if the name is already registered.

const chat = socket.createChannel("chat", { ordered: true });
const game = socket.createChannel("game-events", { ordered: false, maxRetransmits: 0 });

The name is registered in an internal _channelDefs list, so peers that join later automatically get a matching channel attached.

Sending

emit(name, ...args)

chat.emit(eventName: string, ...args: any[]): void

Fans the JSON envelope out to every peer's per-peer channel:

chat.emit("msg", { user: "alice", text: "hi" });

Synchronous. Doesn't wait for delivery.

send(data)

chat.send(data: ArrayBuffer | string): boolean

Raw bytes, fanned out. Returns true only if every peer's per-peer send returned true; if any peer was queueing, returns false.

You probably won't send raw bytes on a broadcast channel — chunked file transfer is better as a per-peer thing. But it works if you have a use case.

Receiving

on(event, handler) / off(event, handler) / once(event, handler)

chat.on(event: string, handler: (...args: any[]) => void): this

Registers a listener that fires for the named event from any peer's per-peer channel underneath. The handler doesn't receive the sender's id directly — if you need it, include it in the payload.

chat.on("msg", (msg) => {
console.log(msg.user, "said", msg.text);
});

Special events dispatched by the broadcast wrapper:

EventArgsFires when
peer-left(peerId)One peer's underlying channel closed
closenoneAll peers gone (auto-fires when the last per-peer channel closes), or you called chat.close()
error(err)Some per-peer channel raised an error
drainnoneA per-peer channel fired drain (so you can resume sending)

Plus any user event name you've emited to it.

chat.on("peer-left", (peerId) => console.log("lost", peerId));
chat.on("close", () => console.log("everyone left"));

peer-left is the broadcast-channel-scoped equivalent of socket.on("peer-disconnect"). It fires only for the per-peer channel underneath, not the whole peer connection.

State

peerCount: number

Live property. Number of peers currently attached to this broadcast channel.

console.log(`${chat.peerCount} people in the channel`);

closed: boolean

Live property. True after chat.close() has been called. A closed broadcast channel won't accept new peers.

Closing

close()

chat.close(): void

Closes every per-peer channel and prevents future late joiners from being attached. Fires close once.

chat.close();
chat.peerCount; // 0
chat.closed; // true

Late joiner attachment

When a new peer's connection comes up:

  1. The library walks _channelDefs (the registry of { name, options } you've created broadcast channels with).
  2. For each one, it creates a matching per-peer DataChannel to the new peer.
  3. The broadcast channel's _addPeer(peerId, channel) is called, which:
    • Saves the channel in the broadcast's peer map.
    • Replays every on(event, handler) subscription onto the new channel (so handlers fire for the new peer's traffic too).
    • Wires drain, error, close from the per-peer channel up to the broadcast.

You don't write any of this — it's automatic. You just call socket.createChannel("chat") once at startup.

Patterns

Chat channel

// Both peers run this on init.
const chat = socket.createChannel("chat", { ordered: true });
chat.on("msg", (msg) => append(msg));

// Send.
chat.emit("msg", { user: userName, text: input.value, time: Date.now() });

Cursor positions (unreliable, frequent)

const cursors = socket.createChannel("cursor", {
ordered: false,
maxRetransmits: 0,
});

cursors.on("pos", ({ peerId, x, y }) => updateCursor(peerId, x, y));

window.addEventListener("mousemove", (e) => {
cursors.emit("pos", { peerId: socket.id, x: e.clientX, y: e.clientY });
});

maxRetransmits: 0 and ordered: false give you the lowest-latency, lossy delivery — perfect for "show roughly where everyone's mouse is" semantics. Lost packets just mean a slightly stale cursor.

Game state

const game = socket.createChannel("game", { ordered: true });

game.on("score", ({ peerId, delta }) => bumpScore(peerId, delta));
game.on("peer-left", (peerId) => removeFromScoreboard(peerId));

Re-using on different connections

The broadcast channel is per-Socket, not per-app. If you create a Socket, then create a second Socket (e.g. for a separate room), each one has its own _channelDefs and its own broadcast channels. The broadcast wrapper does not span sockets.

Limits

  • The broadcast channel itself isn't a separate SCTP stream — it's peerCount separate streams, each with its own buffered amount. chat.send(buf) may queue on some peers and not others.
  • peer-left doesn't tell you why — could be a clean leave, an ICE failure, or a tab close. If you need the reason, listen to socket.on("peer-disconnect") instead.
  • A peer that createChannels a name without you having createChannel-ed it on your side gets a one-sided channel: their sends don't reach you. Both sides need the same createChannel("name") call. Broadcast channels handle this automatically for the broadcast registration — but if you want per-peer custom channels, see socket.peer().createChannel.

Live examples

Ordered, reliable — chat

The classic shape: every peer's text shows up in every other peer's log.

Broadcast chat
A 30-line chat using one createChannel('chat'). Click 'Open 2nd tab ↗' inside the preview to chat with yourself.
src/main.ts
import io from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Broadcast chat · room <code>${ROOM}</code></h1>
<p><small>Every peer (and any peer that joins later) shares one DataChannel.</small></p>
<div id="log" style="height:280px;overflow:auto;background:#0a0908;border:1px solid var(--line);border-radius:8px;padding:10px;font-family:ui-monospace,monospace;font-size:13px"></div>
<form id="form" style="display:flex;gap:8px;margin-top:10px">
<input id="msg" placeholder="say hi…" autocomplete="off" />
<button type="submit">Send</button>
</form>
<p style="margin-top:10px"><small>Joined as <code>${NAME}</code> · click <strong>Open 2nd tab ↗</strong> to chat with yourself.</small></p>
</div>`;

const log = document.getElementById('log')!;
const form = document.getElementById('form') as HTMLFormElement;
const msg = document.getElementById('msg') as HTMLInputElement;

const append = (line: string, dim = false) => {
const row = document.createElement('div');
row.textContent = line;
if (dim) row.style.opacity = '0.55';
log.appendChild(row);
log.scrollTop = log.scrollHeight;
};

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// One broadcast channel, every peer shares it. Late joiners are auto-included
// because the library replays `_channelDefs` on each new peer connection.
const chat = socket.createChannel('chat', { ordered: true });

chat.on('msg', (m: { name: string; text: string }) => {
append(`${m.name}: ${m.text}`);
});

socket.on('peer-connect', ({ id }) => append(`${id.slice(-4)} joined`, true));
socket.on('peer-disconnect', ({ id }) => append(`${id.slice(-4)} left`, true));

form.addEventListener('submit', (e) => {
e.preventDefault();
const text = msg.value.trim();
if (!text) return;
chat.emit('msg', { name: NAME, text });
append(`you: ${text}`);
msg.value = '';
});

Unordered, lossy — cursor sync

Pass { ordered: false, maxRetransmits: 0 } and the SCTP transport stops queuing or retransmitting — the latest cursor position wins, stale frames are dropped on the floor. Right shape for game state, presence indicators, anything where the next packet is more useful than the last.

Unordered DataChannel — cursor sync
Move your mouse over the canvas. Each peer's cursor is synced via an unreliable + unordered DataChannel.
src/main.ts
import io, { type RTCIOBroadcastChannel } from 'rtc.io';
import { setupRoom } from './room';
import './styles.css';

const { ROOM, NAME } = setupRoom();

const app = document.querySelector<HTMLDivElement>('#app')!;
app.innerHTML = `
<div class="card">
<h1>Unordered DataChannel · room <code>${ROOM}</code></h1>
<p><small>Move your mouse over the dark canvas. <code>{ ordered: false, maxRetransmits: 0 }</code> means
the latest position wins — stale frames don't queue up.</small></p>
<div id="canvas" style="position:relative;height:380px;background:#0a0908;border:1px solid var(--line);border-radius:8px;overflow:hidden;cursor:crosshair">
</div>
<p style="margin-top:10px"><small>You are <code>${NAME}</code>.</small></p>
</div>`;

const canvas = document.getElementById('canvas')!;

const socket = io('https://server.rtcio.dev', {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
});

socket.server.emit('join-room', { roomId: ROOM, name: NAME });

// ordered: false + maxRetransmits: 0 = unreliable, unordered SCTP — this
// is the right shape for cursor positions, game state, etc. The library
// uses the same negotiated:true scheme internally; both sides match by name.
const cursors: RTCIOBroadcastChannel = socket.createChannel('cursors', {
ordered: false,
maxRetransmits: 0,
});

const dots = new Map<string, HTMLDivElement>();
const dotFor = (id: string) => {
let d = dots.get(id);
if (d) return d;
d = document.createElement('div');
d.style.cssText = 'position:absolute;width:14px;height:14px;border-radius:50%;background:var(--accent);box-shadow:0 0 12px var(--accent);pointer-events:none;transition:transform 60ms linear;transform:translate(-50%,-50%)';
canvas.appendChild(d);
dots.set(id, d);
return d;
};

cursors.on('move', (m: { id: string; x: number; y: number }) => {
const d = dotFor(m.id);
d.style.left = m.x + 'px';
d.style.top = m.y + 'px';
});

socket.on('peer-disconnect', ({ id }) => {
dots.get(id)?.remove();
dots.delete(id);
});

let last = 0;
canvas.addEventListener('mousemove', (e) => {
const now = performance.now();
if (now - last < 16) return; // rough 60fps cap before backpressure does it for us
last = now;
const r = canvas.getBoundingClientRect();
cursors.emit('move', { id: socket.id, x: e.clientX - r.left, y: e.clientY - r.top });
});