Skip to content

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.

  • The same relay. Only the websocket transport crosses platforms and devices. broadcast is same-browser only, and webrtc is JS-only — so a cross-platform room must use a relay (self-host one). Point every client at the same relayUrl and room id.
  • A compatible codec. json is universal and the safe default. msgpack is 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.

Two layers ride the relay connection:

  1. Relay controljoin / 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 (version 1 or 2, codec json or msgpack).
  2. Transport envelope — each application message is wrapped in a versioned envelope (a legacy v1 envelope, or the modern v2 envelope with an explicit codec) inside a transport frame.

Both SDKs implement both layers, so version and codec differences are negotiated automatically. See RFC-0001 for the full specification.

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.

SignalPayload 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.

A cursor carries two positions:

  • x / ynormalized 0..1 coordinates 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.

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’s PresenceAvatars defaults to name/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.

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:

Terminal window
pnpm --filter @roomful/example-cross-platform-interop dev
# http://127.0.0.1:4175/

Run the Flutter client in the same room:

Terminal window
cd dart/roomful_flutter
flutter run -t example/roomful_flutter_example.dart

Move 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.