HomeGuidesChangelogDiscussions
Hire a ReactVision Expert 🛠️Launch Studio 🖥️Sponsor on GitHub ❤️Log In
Guides

Co-Location

Two or more devices in the same physical space agreeing on one coordinate frame, so content placed by one appears in the same real-world spot for everyone else.

Co-location lets several devices in the same room share one coordinate frame. A box placed by one headset is the same physical box, in the same physical place, for everyone else — and each peer's position flows between devices over a ReactVision-hosted channel.

It is two halves, and they are independent:

  • The frame. Every device recovers the same origin. How is a platform question: phones relocalise against a ReactVision cloud anchor, Quest shares a Meta spatial anchor, visionOS uses ARKit's shared coordinate space. A frame source abstracts the difference.
  • The channel. Devices exchange frame-native data — where each peer is, whether it has localised — over useViroColocation.

Anything else the devices must agree on (placed objects, who is holding what, whose turn it is) is replicated state: a separate, ordered socket over useViroReplicatedState.

📘

Available in ViroReact 3.0

Co-location requires @reactvision/react-viro 3.0 or later and a paid ReactVision plan. A free-tier organisation is refused at the handshake.

⚠️

Same-family only

Phone↔phone, Quest↔Quest, Vision↔Vision. A Quest on a Meta spatial anchor and a phone on a cloud anchor are in unrelated frames, and nothing has ever observed both — there is no conversion between them, yet. This is a deliberate scope decision.


Quick start — phones

import {
  ViroARScene,
  ViroARCloudAnchor,
  ViroBox,
  ViroSphere,
  useViroColocation,
} from "@reactvision/react-viro";

function SharedScene(props) {
  const { arSceneNavigator, cloudAnchorId } = props.sceneNavigator.viroAppProps;
  const [frame, setFrame] = useState<string | null>(null);

  // The anchor id names the frame *and* the channel room.
  const { peers, publishPose } = useViroColocation({
    roomId: cloudAnchorId,
    apiKey: "YOUR_KEY",
    projectId: "YOUR_PROJECT",
    enabled: frame !== null, // nothing to publish before the frame exists
  });

  return (
    <ViroARScene>
      <ViroARCloudAnchor
        cloudAnchorId={cloudAnchorId}
        arSceneNavigator={arSceneNavigator}
        onLocalized={(e) => setFrame(e.transform)}
      >
        {/* One metre in front of the frame origin — on every device. */}
        <ViroBox position={[0, 0, -1]} scale={[0.2, 0.2, 0.2]} />

        {peers.map((p) => (
          <ViroSphere key={p.peerId} radius={0.05} position={p.position} />
        ))}
      </ViroARCloudAnchor>
    </ViroARScene>
  );
}

Device A hosts the space with startScan() / finishScan() on the AR scene navigator; device B needs the resulting cloudAnchorId. Getting it there is what rooms and join codes are for — device A turns the anchor into a room and shows a six-character code, device B types it. Passing the uuid yourself still works if your app already has a channel for it.


The frame

<ViroSharedFrame>

The co-location primitive, independent of how the frame was established. Two devices mounting it with the same source.key, in the same physical space, put their children in the same real-world place.

import {
  ViroSharedFrame,
  cloudAnchorFrameSource,
  metaSpatialAnchorFrameSource,
  visionOSSharedSpaceFrameSource,
} from "@reactvision/react-viro";

const source = isQuest
  ? metaSpatialAnchorFrameSource(groupUuid, "join") // "create" on the host
  : cloudAnchorFrameSource(cloudAnchorId);

<ViroSharedFrame
  source={source}
  arSceneNavigator={arSceneNavigator}
  onLocalized={(e) => setFrame(e.transform)}
  onLocalizeProgress={({ message, attempt }) => setStatus(message)}
  placeholder={<ViroText text="Look around the space…" />}
>
  <ViroBox position={[0, 0, -1]} />
</ViroSharedFrame>;
PropTypeNotes
sourceViroFrameSourceWhere the frame comes from. See Frame sources.
arSceneNavigatornavigatorThe navigator your scene was handed. Passed explicitly — ViroSceneContext carries camera callbacks only, and a scene can host more than one.
onLocalized(event) => voidFired once the frame exists and children become visible. Carries position, rotation, scale and transform.
onLocalizeError(error, state?) => voidThe frame could not be established, including unsupported platforms (state === "ErrorNotSupported").
onLocalizeProgress({ message, attempt }) => voidWhat the source is doing, roughly twice a second. Worth rendering: a cloud-anchor resolve is multi-frame SIFT over a 30-second window.
maxAttemptsnumber (default 3)Re-runs a source that failed for a recoverable reason, one second apart. A missing anchor, an unsupported platform or a rejected key is not retried.
placeholderReactNodeRendered only while the frame is not yet established.

Children are positioned by the scene graph relative to the frame origin, so there is no coordinate maths in app code. A child at [0, 0, -1] is the same physical metre on every device.

<ViroARCloudAnchor>

<ViroSharedFrame> with the cloud-anchor source pre-selected — the phone path. Takes cloudAnchorId in place of source, and the same callbacks and placeholder.

Frame sources

SourcePlatformNotes
cloudAnchorFrameSource(id)iOS, AndroidSIFT relocalisation against a hosted anchor.
metaSpatialAnchorFrameSource(groupUuid, mode)Questmode is "create" on the device that publishes, "join" on the rest. No camera involved.
visionOSSharedSpaceFrameSource(sessionId)visionOSSee the caveat below.

Every source exposes key (which doubles as the channel room id), name and support. Check support.ok before offering the feature — each source declares the platforms it can run on.

📘

visionOS is shaped differently

ARKit aligns the world origin itself across participants rather than handing back an anchor to locate. Once the space converges the frame transform is identity, and content placed at a world position is already in the same physical spot everywhere. <ViroSharedFrame> absorbs this — your component code is unchanged — but ARKit does not move its own alignment data, so you must pump it:

import {
  sharedSpaceNextOutgoing,
  sharedSpacePushIncoming,
} from "@reactvision/react-viro";

const blob = await sharedSpaceNextOutgoing();
if (blob) myTransport.send(blob);

await sharedSpacePushIncoming(receivedBlob);

Until both sides pump, the space never converges and the frame source times out.


Rooms and join codes

A room is what several devices join to share a space, and a uuid is not something anyone can read off another phone. useViroColocationRoom turns the frame this device established into a room with a six-character code, and turns a typed code back into the room and the frame source that goes with it.

import { useViroColocationRoom, ViroSharedFrame } from "@reactvision/react-viro";

// The device that scanned the space, after finishScan() gave it an anchor id.
const host = useViroColocationRoom({
  apiKey,
  projectId,
  host: { frameKind: "cloud_anchor", cloudAnchorId, name: "Bay 3" },
});
// <Text>{host.displayCode}</Text>   →   "K7M 2QX"

// Everyone else, once they have typed it.
const guest = useViroColocationRoom({ apiKey, projectId, joinCode: typed });

<ViroSharedFrame
  source={guest.frameSource}
  arSceneNavigator={arSceneNavigator}
  onLocalized={...}
/>;

// And the same room id for both sockets:
useViroColocation({ roomId: guest.roomId!, apiKey, projectId, enabled: framed });

The hook returns status ("idle" | "working" | "ready" | "failed"), room, roomId, displayCode, frameSource, error and retry.

  • The room says how its devices align, so the joiner picks no frame source of its own: cloud_anchor on phones, meta_group on Quest ("create" for the device that made the room, "join" for the rest), visionos_space on visionOS. Rooms are still same-family.
  • Codes are six characters from an alphabet with no O/0, I/1/L or U, so nothing is ambiguous on a screen at arm's length. Input is case-insensitive and spaces and hyphens are ignored, so k7m 2qx is the same code as K7M2QX. formatJoinCode groups it for display, normaliseJoinCode cleans up what someone typed.
  • A code is scoped to your project. One belonging to another project answers exactly as an unknown one, and you can hold the same code open in two projects without either seeing the other.
  • Rooms last 90 days, matching the cloud anchor they were built on. A room created late in an anchor's life is the shorter of the two.
  • createColocationRoom and lookupColocationRoom are the same two calls without the hook, for apps that already own their own state.

Rooms are a REST call to the platform rather than to the relay, so endpoint here is the platform URL and defaults to it. One request, before a session starts; the frame acquisition that follows takes far longer (12–30 seconds on phones).


The channel

const { available, state, localPeerId, peers, error, publishPose } =
  useViroColocation({
    roomId,     // the same id that names the frame
    apiKey,
    projectId,
    endpoint,   // optional; defaults to the production relay
    enabled,    // default true
    pollMs,     // default 33
  });

state is "idle" | "joining" | "joined" | "reconnecting" | "failed". Each peer carries peerId, position, rotation (quaternion [x, y, z, w]), timestampMs and localized.

timestampMs is the sender's clock. It orders one peer's own updates and nothing more — device clocks are not synchronised, so never compare it across peers.

Imperative equivalents exist for non-React use: isColocationAvailable, joinColocation, leaveColocation, setColocationLocalPose, getColocationState, getColocationPeers.

The coordinate contract

⚠️

Send location-frame coordinates. Never world coordinates.

World coordinates are per-session — each AR session picks its origin wherever tracking started — so a world position means nothing to the peer receiving it. The wire schema has no way to express one.

import {
  parseLocationTransform,
  worldToLocation,
  locationToWorld,
  invertTransform,
} from "@reactvision/react-viro";

const frame = parseLocationTransform(transformFromOnLocalized)!;

// Before sending your own object's position:
const forTheWire = worldToLocation(frame, myObjectWorldPosition);

// After receiving a peer's:
const hereInMyWorld = locationToWorld(frame, theirPosition);

peers[].position is already in the frame, so a peer marker rendered inside <ViroSharedFrame> needs no conversion at all — the scene graph does it. Conversion is only for content you keep outside the frame node.

invertTransform turns a location transform into the transform back. You need it if you are composing frames yourself — placing content relative to one anchor while receiving it relative to another.

Publishing a pose

publishPose takes 16 column-major floats as a string. poseCsv builds it from a position and a look direction, which is safer than assembling it by hand: the camera looks down its own −Z, so the +Z basis is the negated forward. Reversing that points every avatar away from where its device is pointing, in a way that still looks like a plausible scene.

publishPose(
  poseCsv(
    locationToWorld(inverse, camera.position),
    transformDirection(inverse, camera.forward),
    transformDirection(inverse, camera.up),
  ),
);

Use transformDirection for a direction. locationToWorld moves a point, so a forward vector through it comes back displaced by the frame's origin.

Publishing more often than VIRO_POSE_INTERVAL_MS (50 ms) puts nothing extra on the wire — native drops anything faster than poseSendHz. The constant is exported so an app driving from a camera callback at 90 Hz does not format 16 floats for every frame that never reaches the socket.

Smoothing

Poses go out at 20 Hz by default, so a marker driven straight from peers moves in 50 ms jumps. That rate assumes the receiver fills in between samples, which is useViroSmoothedPeers:

const { peers } = useViroColocation({ roomId, apiKey, projectId });
const smoothPeers = useViroSmoothedPeers(peers);

useViroSmoothedEntities does the same for replicated transforms, naming the fields to blend so a score or a step index is never averaged on the way to the screen:

const smooth = useViroSmoothedEntities(
  entities,
  { position: "vec3" },
  { localPeerId },
);

Pass localPeerId wherever anything can be dragged. Entities this device owns are then passed straight through: there is no gap to fill in, because the renderer is already moving that node under the finger every frame.

Both take halfLifeMs, defaulting to 35. Smoothing buys continuity with lag — at 35 ms the rendered value sits roughly 50 ms behind a continuously moving target. Worth paying for a marker, where a 100 ms jump reads as unreliable; worth tuning down where the exact position matters more than the motion.

Updates land at frame rate while anything is moving and stop entirely once everything settles, so call these in the component that draws the moving things rather than one that draws the whole scene.

Smoothing something the hooks do not cover

Both hooks are built on three exported primitives, for a value neither knows about — a camera, a scalar, a node you drive yourself:

import {
  approachFactor,
  approachVec3,
  approachQuat,
} from "@reactvision/react-viro";

const t = approachFactor(deltaMs, halfLifeMs); // 0..1
const next = approachVec3(current, target, t);
const nextRot = approachQuat(current, target, t); // shortest arc

approachFactor converts a half-life and a frame duration into a blend weight, which is what makes the result frame-rate independent. A fixed per-frame fraction converges twice as fast at 90 Hz as at 45, so the same code feels different on a headset and a phone. Feed it the real frame delta and it does not.

A frame long enough to cover several half-lives returns 1, which snaps. That is deliberate: after the app has been backgrounded you want the object where it is now, not a glide in from where the room was a minute ago.


Replicated state

The channel carries frame-native data only: poses and presence. Everything else two devices must agree on — whose turn it is, which objects have been placed, who is holding what — is replicated state. It rides a second socket at /functions/v1/replication/{roomId}, with the same room id and the same credentials.

import { useViroReplicatedState } from "@reactvision/react-viro";

const { entities, byId, claim, release, set, remove, clear, isMine } =
  useViroReplicatedState({
    roomId, // the same id that names the frame and the channel
    apiKey,
    projectId,
    enabled: frame !== null,
    onReject: (r) => console.warn(r.reason, r.current),
  });

// Grab it, then move it. While held, nobody else can write to it.
claim("cone-3");
if (isMine("cone-3")) {
  set("cone-3", { position: [0, 0, -1] }, { optimistic: true });
}

Two sockets rather than two message types on one, because poses originate in C++ at frame rate and must not cross the JS bridge, while application state originates in JS and must not be lossy. One socket would make each pay the other's cost.

The model

An entity is { id, fields, version, owner }. The server orders every operation, accepts or refuses it, and broadcasts the result, so every device converges on the same state in the same order. fields is yours; the server never interprets it.

  • Ownership gates mutation. claim takes an entity, creating it if it does not exist yet, so placing and holding is one step. While a peer owns it, only that peer may set or remove it. Two devices grabbing the same object therefore resolve: the first claim wins, the second is refused already-owned with the current value attached, so the loser renders the in-use state rather than guessing it.
  • A departing peer releases everything it held. A device that crashes mid-grab does not lock that object for the life of the room.
  • Unowned entities are last-writer-wins. Pass expectVersion to opt into optimistic concurrency instead: the write is refused version-conflict if the entity moved on, and the current value comes back with the refusal.
  • optimistic is per write and off by default. The default costs one round trip and never shows a value that turns out not to be true. Turn it on for something being dragged, where a round trip per frame is visible.
  • clear is the one operation that ignores ownership, and it has to be. A reset issued as one remove per entity is refused not-owner on everything anyone is holding, so exactly the objects still in someone's hand would survive it. Any peer may call clear; it empties the room for everyone and arrives as ordinary deletes, so a device that was not listening picks it up on its next resync.

Refusals reach onReject as one of not-owner, version-conflict, already-owned, no-such-entity, malformed, too-many-entities, field-too-large, room-too-large or org-too-large.

Limits per room: 512 entities and 16 KB of serialised fields per entity. org-too-large is the one that is not about this room — a team's open rooms add up to a ceiling on the relay, so the answer is to reset or close a room rather than shrink this write.

Coordinates, again

Positions stored here are location-frame coordinates, exactly like poses on the channel. The same worldToLocation / locationToWorld conversion applies, for the same reason.

Writing at a sane rate

The relay allows 120 messages a second per peer here and closes the socket at 1008 above it, which also refuses that key for the next 30 seconds. Drag callbacks arrive every frame — 72 to 90 a second on a headset — so a write wired straight to one exceeds that on its own, and two held objects exceed it twice over.

useViroThrottledWrite holds the rate and keeps the last value, which plain throttling would drop and leave the object a frame short of where the hand let go:

const drag = useViroThrottledWrite(
  (position: ViroVec3) =>
    replication.set(id, { position }, { optimistic: true }),
  { unchanged: viroVec3Settled(0.002) },
);
// onDrag:     drag.push(positionInFrame)
// on release: drag.flush(); replication.release(id);

The interval defaults to VIRO_REPLICATION_WRITE_INTERVAL_MS, which is exported so an app that writes on its own timer can pace itself against the same number rather than guessing one that happens to stay under the limit.

unchanged is a deadband, and it is where most of the saving is: placing something precisely is mostly slow movement, and skipping those writes costs no latency at all. It is measured against what was last sent rather than the previous sample, so a slow drag cannot creep any distance one sub-threshold step at a time.

Leave a dragged node to the renderer

The renderer moves a dragged node itself, every frame, straight to where the ray points, and onDrag reports where it put it. Setting position at the same time gives that node two authors, and the one arriving through React is always the older: it has been through the write interval, the round trip and the smoother. On the device doing the dragging that reads as the object trailing the hand and snapping back a few centimetres, worse the faster the drag.

So render the replicated position for what other peers are holding, and leave what this device is holding alone:

const [pinned, setPinned] = useState<ViroVec3 | null>(null);
// ClickDown: setPinned(position); replication.claim(id)
// ClickUp:   drag.flush(); replication.release(id); setPinned(null)

<ViroBox position={pinned ?? position} dragType="FixedDistance" />;

Pinned rather than dropped, because React Native writes a prop only when its value changes, so an unchanged array never reaches the renderer at all. Flush before lifting the pin, so the position that comes back is the one the drag ended on. Clear the pin if ownership is lost without a ClickUp, which a dropped socket or a peer taking the object will do.

Outside React — ViroReplicationClient

useViroReplicatedState is a thin hook over ViroReplicationClient, exported for the cases a hook cannot serve: game logic in a plain module, a store you own, or anything that has to outlive the component that started it.

import { ViroReplicationClient } from "@reactvision/react-viro";

const client = new ViroReplicationClient();
const stop = client.subscribe(() => render(client.getEntities()));
client.connect({ roomId, apiKey, projectId });

client.claim("crate");
client.set("crate", { position }, { optimistic: true });
client.release("crate");

stop();
client.disconnect();

It carries the same model as the hook — claim / release / set / delete / clear, get and getEntities, plus state, localPeerId and error — and notifies through subscribe, which returns its own unsubscribe.

⚠️

Nothing disconnects a client for you. A hook unmounts; a client does not. One that outlives its screen keeps a socket open and keeps counting against the room.

What is saved, and what is not

Replicated state is saved. The hosted relay writes a room back to the platform when its last peer leaves, on a sweep while a session is running, and again when it is restarted, and reads that copy back when the room is next opened. Objects placed in a work zone are still there tomorrow, and a deploy mid-session costs a reconnect rather than the room.

Three things follow:

  • Owners do not survive. An entity comes back unowned, because the peer that was holding it belongs to a session that has ended. Claim it again rather than assuming it is still yours.
  • A room caps at 1 MB of serialised entities, alongside the 512-entity and 16 KB-per-entity limits. A write that would cross it is rejected room-too-large; deleting an entity frees the room up again.
  • A room's saved copy can be unavailable. If the platform cannot answer, the relay refuses the connection with a 503 rather than opening an empty room, and the client retries. An empty room broadcast as the truth would delete the work zone for everyone.

A room idle for 90 days is deleted with the anchor it was built on.


Platform support

FrameChannelReplicated state
iOS / Android phone✅ cloud anchor
Meta Quest✅ Meta spatial anchor
visionOS✅ ARKit shared space
Web

Cloud anchors specifically are not available on either headset, and structurally so: Quest's OpenXR session produces no camera image for the SIFT localiser and stubs cloud anchors outright, and visionOS builds exclude the AR subsystem entirely while gating passthrough camera access behind an enterprise entitlement. cloudAnchorFrameSource reports this through support.ok === false with the reason attached, and <ViroARCloudAnchor> warns once and reports ErrorNotSupported rather than failing slowly.

The channel and replication need no camera and work on all three native platforms.


The service

Both sockets are served by ReactVision's hosted relay — the channel at /functions/v1/colocation/{roomId} and replication at /functions/v1/replication/{roomId}. endpoint defaults to it, so most apps pass apiKey and projectId and nothing else.

  • Rooms are scoped to your project. The room key is your organisation, your project and the room id, so two apps that happen to pick the same room id never meet. Both devices must send the same projectId, not just the same roomId.
  • Room ids are case-sensitive, at most 128 characters, and limited to A-Za-z0-9._~:@+-. A cloud anchor id or a Meta group uuid already fits; an id you invent yourself should stay inside that set.
  • Co-location needs a paid plan. A free-tier organisation is refused at the handshake and the hook reports failed. Upgrade the team in its billing settings in ReactVision Studio.
  • A refusal fails fast; a network problem is retried. A handshake answered 400, 401 or 403 is a decision that will not change, so the pose channel reports failed at once instead of retrying for 15.5 seconds. A close with reason auth-revoked is treated the same way. Everything else keeps the five-attempt backoff, including the other 1008 reasons (rate-limited, too-large, slow-consumer) and the 404 you get while the relay is restarting. Replicated state fails fast on auth-revoked only — the WebSocket API hides the HTTP status of a failed upgrade from JavaScript.
  • Credentials go in headers, x-api-key and x-project-id. Passing them in the query string is refused, because a key in a URL reaches proxy and server logs. A browser cannot set headers at all, which is why web co-location is not supported yet.
  • A restart makes every peer rejoin with a new peer id. Other devices see the old id leave and a new one join. Key your own per-peer state on something the peer tells you, never on peerId surviving a reconnect. The frame is unaffected: reconnecting never re-localises, and replicated state is reloaded from its saved copy.

Running a server locally

For LAN development there is a reference implementation in the reactvisioncca repository under server/, in Deno:

cd server
deno task dev        # :8787
deno task test

Point devices at it with endpoint: 'http://<your-lan-ip>:8787'; the client turns http:// into ws:// itself. Use the LAN address, not localhost — on a phone, localhost is the phone.

⚠️

It is a reference, not a deployment. A room is held by one process and saved back when its last peer leaves, so two peers only meet if they land on the same instance. Its credential check confirms the key and project id are present and nothing more.


Troubleshooting

SymptomLikely cause
ErrorNotSupported from onLocalizeErrorCloud anchors on a headset. Use the platform's own frame source.
Localisation never completes on a phoneThe space was scanned too small, or you are not looking at what was scanned. The coverage gate needs ≥40 points, ≥1 m spread and ≥5 viewpoint pairs — walk, don't rotate in place.
available === false from the hookReactVisionCCA is not linked in this build, or the platform has no WebSocket transport.
Joined, but peers stays emptyBoth devices must use the same roomId and the same projectId — rooms are scoped per project. Against a local reference server they must also reach the same process.
failed at once, with no reconnectThe handshake was refused, not dropped. A free-tier organisation, a key that does not belong to that project, or a room id outside the permitted characters.
failed part way through a sessionThe relay closed the socket auth-revoked: the API key was revoked or the team's plan lapsed mid-session. Retrying cannot change it, so the client stops.
visionOS frame source times outOnly one side is pumping alignment data. Both must call sharedSpaceNextOutgoing / sharedSpacePushIncoming.
Peers appear in the wrong placeWorld coordinates went on the wire. Convert with worldToLocation before sending.
A dragged object rubber-bandsThe replicated position is being written to the node the renderer is already dragging. Pin it — see Leave a dragged node to the renderer.

See also

  • Cloud anchorsstartScan() / finishScan() on the AR scene navigator, which is where a phone's cloudAnchorId comes from.
  • Quest setup — building for Meta spatial anchors.
  • visionOS setup — the shared coordinate space and its entitlements.