Building multi-user experiences with co-location

Co-location is the bit of ViroReact 3.0 that turns "multi-user AR" into reality. Two or more devices in the same physical room agree on one coordinate frame, exchange poses, and share application state without you inventing a netcode layer from scratch.
This post is an overview with enough code to get you started, but the full docs live here if you need them, and our MCP server is also on hand to help.
The mental model: three pieces
Co-location is not one API. It is three cooperating parts.
- The frame. Every device recovers the same origin. How depends on the platform: phones relocalise against a ReactVision cloud anchor, Quest shares a Meta spatial anchor, visionOS uses ARKit's shared coordinate space. A frame source abstracts that difference.
- The channel. Devices exchange frame-native data (where each peer is, whether they have localised) over
useViroColocation. - Replicated state. Everything else the room must agree on (placed objects, who is holding what, whose turn it is) rides a separate ordered socket via
useViroReplicatedState.
Those sockets are separate on purpose. Poses originate in C++ at frame rate and should not cross the JS bridge. Application state originates in JS and must not be lossy. One socket would make each pay the other's cost.
Same-family only, for now
Phone with phone. Quest with Quest. Vision with Vision. A Quest on a Meta anchor and a phone on a cloud anchor live in unrelated frames. There is no conversion between them. That is a deliberate scope decision, not a missing checkbox.
If your product needs mixed device families in one session, say so on Discord. For now, design rooms around one family.
Quick start on phones
import {
ViroARScene,
ViroARCloudAnchor,
ViroBox,
ViroSphere,
useViroColocation,
} from "@reactvision/react-viro";
import { useState } from "react";
function SharedScene(props) {
const { arSceneNavigator, cloudAnchorId } = props.sceneNavigator.viroAppProps;
const [frame, setFrame] = useState(null);
const { peers, publishPose } = useViroColocation({
roomId: cloudAnchorId,
apiKey: "YOUR_KEY",
projectId: "YOUR_PROJECT",
enabled: frame !== null,
});
return (
<ViroARScene>
<ViroARCloudAnchor
cloudAnchorId={cloudAnchorId}
arSceneNavigator={arSceneNavigator}
onLocalized={(e) => setFrame(e.transform)}
>
<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>
);
}Children of ViroARCloudAnchor sit in the location frame. A box at [0, 0, -1] is the same physical metre on every device. No coordinate maths in app code for that path.
Device A hosts the space with startScan() / finishScan() on the AR scene navigator. Device B needs the resulting cloudAnchorId. Rooms and join codes (below) are how you get that id across without pasting UUIDs.
ViroSharedFrame and frame sources
ViroARCloudAnchor is the phone-shaped wrapper. Underneath it is ViroSharedFrame plus a frame source:
import {
ViroSharedFrame,
cloudAnchorFrameSource,
metaSpatialAnchorFrameSource,
visionOSSharedSpaceFrameSource,
isQuest,
} from "@reactvision/react-viro";
const source = isQuest
? metaSpatialAnchorFrameSource(groupUuid, "join") // "create" on the host
: cloudAnchorFrameSource(cloudAnchorId);
<ViroSharedFrame
source={source}
arSceneNavigator={nav}
onLocalized={...}
>
<ViroBox position={[0, 0, -1]} />
</ViroSharedFrame>| Source | Platform | Notes |
| --- | --- | --- |
| cloudAnchorFrameSource(id) | iOS, Android | SIFT relocalisation against a hosted anchor |
| metaSpatialAnchorFrameSource(groupUuid, mode) | Quest | "create" or "join". Needs a ViroARScene root. Fixed properly in 3.0.1. |
| visionOSSharedSpaceFrameSource(sessionId) | visionOS | ARKit aligns the world origin; your app must pump alignment blobs |
Check source.support.ok before offering the feature in UI.
Rooms and join codes
Nobody wants to read a UUID off another phone. useViroColocationRoom turns a frame into a room with a six-character code, and turns a typed code back into the room and its frame source.
import { useViroColocationRoom, ViroSharedFrame, useViroColocation } from "@reactvision/react-viro";
// Host, after finishScan() gave you an anchor id:
const host = useViroColocationRoom({
apiKey,
projectId,
host: { frameKind: "cloud_anchor", cloudAnchorId, name: "Bay 3" },
});
// Show host.displayCode → something like "K7M 2QX"
// Guest, once they typed it:
const guest = useViroColocationRoom({ apiKey, projectId, joinCode: typed });
<ViroSharedFrame
source={guest.frameSource}
arSceneNavigator={arSceneNavigator}
onLocalized={...}
/>
useViroColocation({
roomId: guest.roomId!,
apiKey,
projectId,
enabled: framed,
});Codes are six characters from an unambiguous alphabet (no O/0, I/1/L, or U). Input is case-insensitive; spaces and hyphens are ignored. Codes are scoped to your project. Rooms last up to 90 days, matching the cloud anchor they sit on.
Frame kinds on rooms: cloud_anchor, meta_group, visionos_space.
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 a peer. Helpers exist when you need to convert:
import {
parseLocationTransform,
worldToLocation,
locationToWorld,
} from "@reactvision/react-viro";
const frame = parseLocationTransform(transformFromOnLocalized)!;
const forTheWire = worldToLocation(frame, myObjectWorldPosition);
const hereInMyWorld = locationToWorld(frame, theirPosition);Peer markers rendered inside ViroSharedFrame need no conversion. The scene graph already puts them in the right place.
Replicated state
import { useViroReplicatedState } from "@reactvision/react-viro";
const { entities, claim, release, set, isMine } = useViroReplicatedState({
roomId, // same id as the frame and the channel
apiKey,
projectId,
enabled: frame !== null,
onReject: (r) => console.warn(r.reason, r.current),
});
claim("cone-3");
if (isMine("cone-3")) {
set("cone-3", { position: [0, 0, -1] }, { optimistic: true });
}Ownership gates mutation: the first claim wins, the second is refused with the current owner attached. Unowned entities are last-writer-wins unless you pass expectVersion. Optimistic writes are opt-in per call. A peer that disconnects releases whatever it held.
Positions stored here are location-frame coordinates too, for the same reason as poses.
For drag-heavy UIs, pair with useViroThrottledWrite and useViroSmoothedEntities / useViroSmoothedPeers so you do not flood the relay or show 50 ms pose jumps.
Quest specifics
On Quest, use metaSpatialAnchorFrameSource and root the scene in ViroARScene. A fully virtual ViroScene has no AR session and no anchor to share; the call tells you so rather than failing quietly.
Upgrade to @reactvision/react-viro 3.0.1 (or newer) and re-run Expo prebuild. Without that permission the Meta runtime hides the group-sharing extension entirely, which looks like "this headset does not support shared anchors" rather than a clear error.
Where to go next
Start from the co-location guide in the docs, or ask the ReactVision MCP for CO_LOCATION if you are building with an agent. Come find us at the events below if you want to talk through a multi-user design before you write too much.
Same room. Same frame. Same codebase.
Upcoming events
Events that fit multi-user and platform-agnostic XR. Full details: reactvision.xyz/community/events
- Ctrl+Space: Expo x ReactVision Hackathon - San Francisco 3 October 2026; London 8 October; Toronto 10 October; Bengaluru 18 October
- Virtual, 6 October: Verifying the Unverifiable: Testing AI-Generated Code
- Virtual, 10 November: The Next Platform Shift
- Virtual, 17 November: Building Platform-Agnostic AR/VR