Documentation ziprack/1SDK 0.3.0

Capabilities

Broadcast

broadcast@1capability contract

On this page

Introduction

broadcast@1 is what makes group work repeatable: a climb with base camps, teams on separate boards, a live trivia round, a facilitated card game. It gives your applet rooms, presence, published events, a facilitator role and a shared key/value state.

The Rack holds the socket, not the applet. You publish and subscribe over postMessage, while the Rack owns the real-time connection and its authentication, so your applet never handles a channel token. Nothing in the applet API depends on which real-time transport a Rack uses, and your applet behaves the same on any Rack.

Rooms

A room is an applet plus its members. Every member has exactly one of two roles, participant or facilitator. Membership is orthogonal to attempts: a room may contain attempts (each person running their own inside a shared session) or none at all.

Rooms are created on the Rack, outside your applet, and participants arrive by join code or QR. Each room has a mode that decides how the facilitator role is assigned:

Mode Facilitator assignment
opt_in Anyone in the room may claim the role.
first_in Granted automatically to the first joiner.
roster A preset list of people, resolved through auth@1.

Only roster depends on auth@1, so light group activities can run without an identity source at all.

The room arrives in launch data, and you may read it from Rack.broadcast:

TypeScript
Rack.broadcast.room;            // { id, code, name, status, mode } | null
Rack.broadcast.role;            // 'participant' | 'facilitator' | null
Rack.broadcast.isFacilitator;
Rack.broadcast.members();       // RoomMember[]

room is null when the applet was launched outside a room. Handle it: the same applet is often launched both ways.

Declaring Channels and Facilitator Events

You declare your channels, and the events only a facilitator may send, in the manifest's broadcast section:

JSON
"broadcast": {
    "channels": ["main", "board"],
    "facilitator_events": ["advance", "lock", "reveal"]
}

Channels default to ["main"] if you declare none, and cap at 20. They are namespaced to the room, so an applet cannot address another room's channel. The name $room is reserved for the Rack.

facilitator_events is enforcement, not documentation. An event named there is refused with forbidden unless the sender currently holds the facilitator role.

Facilitator control is therefore not a separate capability. Advancing, locking and revealing are ordinary events the Rack polices, and your applet decides what they mean.

Joining and Publishing

To get started, you may join a channel using the join method. The returned channel lets you listen for events and publish them:

TypeScript
const main = await Rack.broadcast.join('main');

const stop = main.listen<{ step: number }>('advance', (payload, from) => {
    goToStep(payload.step);                       // from.participant_id, from.role
});

main.listen('*', ({ event, payload }) => log(event, payload));

if (Rack.broadcast.isFacilitator) {
    await main.publish('advance', { step: 3 });
}

stop();                 // unsubscribe one listener
await main.leave();     // leave the channel

In this example, the applet listens for advance events, logs every event through the * wildcard, and publishes advance only when the current member is the facilitator. Calling the function listen returns removes that one listener, while leave leaves the channel entirely.

Payloads cap at 16 KB. Every published event is persisted, so the Rack can replay a session's facilitator timeline afterwards.

Events are delivered to everyone in the room, including the sender. Design handlers to be idempotent, and to tolerate arriving out of order relative to your own optimistic updates.

Inviting People In

An applet on a projector often is the way people get into the room. You may ask the Rack where to point them using the invite method:

TypeScript
const { ok, data } = await Rack.broadcast.invite();
// data → { code: '7FQK2M', url: 'https://rack.example.com/r/7FQK2M' }

A sandboxed applet cannot see the Rack's host, so the Rack hands you the URL. What you do with it is yours: print the code on a slide, render the URL as a QR with whatever library you already bundle, or both:

TypeScript
if (ok) {
    board.append(qr(data.url));       // your encoder, your styling
    code.textContent = data.code;
}

There is no facilitator gate, so any member may call it, which keeps the invite working while nobody holds the role. The room code is also in every member's launch payload.

The call is refused with conflict once the room is locked or closed, because the join URL turns those scanners away. Handle it, since that is the signal to take a stale QR off the screen:

TypeScript
Rack.broadcast.on('status', async ({ status }) => {
    board.hidden = status !== 'open';
});

Non-members get forbidden, as they do for presence. In mock mode, the invite is the dev server page itself, so scanning it opens the second tab that forms the room.

Presence

You may ask who is in the room using the presence method, or listen for changes with on:

TypeScript
const { data } = await Rack.broadcast.presence();
data.members;   // [{ id, name, role, present }]

Rack.broadcast.on('presence', ({ members }) => renderRoster(members));

A member counts as present when seen within the last 45 seconds, and the Rack heartbeats for you. Treat presence as approximate: it is a roster, not a lock.

Room Events

Beyond your own channel events, you may subscribe to the following room-level events with Rack.broadcast.on:

Event Payload
presence { members }
state { key, value, version }
role { role, participant_id }
status { status }: open, locked or closed

Shared State

Shared state is a small key/value store for the room. It is server-authoritative, last write wins per key, and the Rack stamps each write with a version:

TypeScript
await Rack.broadcast.state.set('phase', 'voting');

const { data } = await Rack.broadcast.state.get(['phase', 'timer']);
Rack.broadcast.state.snapshot();       // launch snapshot + everything pushed since, synchronously

Rack.broadcast.on('state', ({ key, value, version }) => apply(key, value));

A room holds up to 200 keys, at 16 KB per value. Use it for the small facts everyone must agree on, such as the current phase, which card is face up, or whether submissions are open. For the content itself, use db@1 room collections.

Shared state is not a CRDT. If two people write the same key at the same moment, one of them wins and everyone converges on that value. Design keys so that matters as little as possible: one writer per key is the intended shape, and a facilitator-only key is the easiest way to get it.

The Facilitator Role

In an opt_in room, a member may claim the facilitator role using the claim method. Whoever holds the role may hand it on with transfer:

TypeScript
await Rack.broadcast.claim();                 // opt_in rooms
await Rack.broadcast.transfer(participantId); // hand it on

Rack.broadcast.on('role', ({ role, participant_id }) => {
    if (participant_id === launch.actor.id) renderFacilitatorControls(role === 'facilitator');
});

The role does not survive a dropped connection. The Rack vacates the role from a facilitator unseen for 60 seconds, and in a first_in room it reassigns the role automatically, so a closed laptop does not strand the room.

Build the participant view first, and layer facilitator controls on top of it. Every applet using rooms must be usable in the window where nobody holds the role.

Developing Against Rooms

The SDK's mock mode implements rooms over BroadcastChannel, so two tabs of your dev server form a room. Presence, published events, shared state and role transfer all work between them, and the first tab to open takes the facilitator role by default.

If you would like to rehearse opt_in claiming locally, you may add mock_mode to your broadcast section:

JSON
"broadcast": { "channels": ["main"], "facilitator_events": ["advance"], "mock_mode": "opt_in" }

mock_mode is read only by the mock. For automated tests, you may drive the other side of the room explicitly:

TypeScript
host.push({ event: 'advance', payload: { step: 2 }, channel: 'main',
            from: { participant_id: 99, role: 'facilitator' } });