Cross-platform interop
Audience: users.
Roomful’s JavaScript SDK (@roomful/core and the framework adapters) and source-alpha Dart/Flutter
SDKs (roomful, roomful_flutter) speak one wire protocol (RFC-0001).
A React web client and a Flutter mobile client can therefore share the same room — presence,
cursors, shared state, and events flow between them. The shared contract is proven by the
cross-SDK vectors in protocol-fixtures/, which both SDKs decode
identically.
The Dart/Flutter packages are currently used from this repository source tree. pub.dev publication is
pending, and roomful_flutter still depends on roomful by local path.
This page covers what interop requires and the payload shapes every client must agree on.
What interop requires
Section titled “What interop requires”- The same relay. Only the
websockettransport crosses platforms and devices.broadcastis same-browser only, andwebrtcis JS-only — so a cross-platform room must use a relay (self-host one). Point every client at the samerelayUrland room id. - A compatible codec.
jsonis universal and the safe default.msgpackis opt-in and only used when both peers negotiate it over a binary transport; a JSON peer and a MessagePack peer still interoperate because the relay re-encodes per recipient. - Matching auth. If the relay enforces auth, every client presents a JWT signed with the same secret — see Auth providers.
Protocol layers
Section titled “Protocol layers”Two layers ride the relay connection:
- Relay control —
join/joined/peer-joined/peer-left/transport/error. On join, a peer advertises its capabilities (protocol version range + codecs); the relay and peers negotiate a shared session (version1or2, codecjsonormsgpack). - Transport envelope — each application message is wrapped in a versioned envelope (a legacy
v1envelope, or the modernv2envelope with an explicitcodec) inside atransportframe.
Both SDKs implement both layers, so version and codec differences are negotiated automatically. See RFC-0001 for the full specification.
Message payload contracts
Section titled “Message payload contracts”The relay validates transport payloads and silently drops any that don’t match. So every SDK —
and any message you build by hand — must send these exact shapes. Application fields (a user’s
name, color, etc.) ride as extra properties on top.
| Signal | Payload shape |
|---|---|
presence:update | { peer: { id, joinedAt, lastSeen, ...yourPresence } } |
cursor:update | { cursor: { userId, name, color, x, y, xAbsolute, yAbsolute, idle, element? } } |
state:update | { value, history, vectorClock, changedBy, timestamp, reason } (reason: set/patch/…) |
event | { name, payload, loopback? } |
Both SDKs fill the required peer and cursor fields for you: presence is completed
automatically, and cursors go through useCursors (JS) or CursorsEngine.setPosition (Dart). When
you emit these by hand instead, include all required fields — a presence:update whose peer
omits joinedAt/lastSeen, or a cursor:update missing userId/xAbsolute/idle, is rejected by
the relay and never reaches other peers. state:update and event payloads are identical across
SDKs.
Coordinate space
Section titled “Coordinate space”A cursor carries two positions:
x/y— normalized0..1coordinates relative to a shared surface. These are the interop-safe coordinates: a normalized point maps correctly onto any viewport, so a cursor from a 1440px-wide web canvas lands in the right place on a 390px-wide phone.xAbsolute/yAbsolute— absolute pixel coordinates, meaningful only within the sender’s own viewport.
For cross-platform rooms, drive rendering from the normalized x/y and multiply by the local
surface size. Flutter’s LiveCursorsOverlay does this when constructed with normalized: true.
Shared conventions
Section titled “Shared conventions”The protocol carries the shapes above; the field names inside your presence and shared state are your app’s contract, and every platform in the room must agree on them:
- Presence — pick stable keys (
name,color, …) and use them identically in the React and Flutter clients.roomful_flutter’sPresenceAvatarsdefaults toname/color. - Shared state — agree on the value shape. The core keeps one last-write-wins value per room;
nest fields in a
Map/object if you need several. - Events — agree on event
names and payload shapes.
Run the cross-platform demo
Section titled “Run the cross-platform demo”Two example clients ship in the repo and default to the same relay and room — the public
wss://relay.roomful.dev — so they collaborate across platforms out of the box:
examples/cross-platform-interop (React web) and the
roomful_flutter example
(Flutter).
Run the web client:
pnpm --filter @roomful/example-cross-platform-interop dev# http://127.0.0.1:4175/Run the Flutter client in the same room:
cd dart/roomful_flutterflutter run -t example/roomful_flutter_example.dartMove your cursor in either client and watch presence and cursors appear in the other.
To run against your own relay instead, start one (docker compose up — relay on
ws://localhost:8787) and point both clients at it: the web client with ?relay=ws://localhost:8787,
the Flutter client with --dart-define=ROOMFUL_RELAY_URL=ws://<your-host>:8787.