document.collaborate() and use it for its own multiplayer - players walking around a map, cursors on a canvas, a shared vote - without running a server.
A room carries two things, and they behave differently:
Getting a room
A room belongs to a file, and an app reaches the room of itsdocument. There are two ways to give an app one, and they decide who is in the room and what it can hold:
Presence for every launch
Every.app launcher (the file that gives an app its name and icon in Apps) is that app’s room. Open the launcher - from Apps, the activity rail or Files - and document.current() describes it, with editor: "app" and readOnly: true, and collaborate() joins everyone else who opened the same launcher. The app’s relative paths still resolve beside its own source, not beside the launcher.
This room carries presence only. The launcher’s bytes are its manifest, so document.write is refused and session.update does nothing. An .html or .tsx file opened directly, rather than through a launcher, has no document.
A shared file for shared state
For state that everyone edits and that lasts, register the app for a file type in its launcher, then have people open a file of that type:/apps/farm.app
team.farm offers Farm in its App switcher, and everyone who opens team.farm with it is in the same room. Any extension works, including one Arg has no editor for: such a file opens in the plain text editor, which has a room. A different file is a different room, so team.farm and practice.farm are separate games.
Formats with no room of their own - a PDF, a .docx - reject collaborate() with unsupported_capability.
Joining
import("@arg-ai/sdk").catch(() => null) if the page should also run where Arg is not hosting it, such as a share link, and fall back to a single-player mode there.
session.readOnly is true in a launcher’s room, and in a file’s room when this viewer may not write the file (a viewer grant, a read-only Workspace access grant, a locked file). Presence still works, and updates still arrive; session.update does nothing.
Presence
session.awareness is the room’s presence. Each viewer has one entry, keyed by a numeric clientId:
user is filled in by Arg for every signed-in viewer - name, a stable colour, avatar - so you do not need auth.principal() to label someone. Everything else is yours.
- Your own entry is in
states(). Skip the one whoseclientIdissession.awareness.clientId. - Not everyone in the room is running your app. Someone with the same file open in its regular editor has a
userand nothing else. Key what you draw on your own field. - Your fields are merged into the viewer’s entry, never replacing it.
setLocalState({ player })setsplayerand leaves the rest;setLocalState(null)clears only the fields your app published.useris never written, so an app cannot remove a person from the file’s collaborator list. Use a field name specific to your app, so it cannot collide with the editor’s own cursor fields. - Presence is relayed to every viewer in the room. Send on change, and cap a moving value at around ten updates a second; interpolate between them on the receiving side. There is no need to re-send an unchanged state to keep it alive.
onChange fires without it.
Shared state
Updates are Yjs update bytes. The SDK has no dependency on yjs, so import your own copy and connect it once:initialState before any live update - it is the whole room as one update. Pass session as the origin when applying the room’s updates, so the update listener can tell them from your own; sending the room’s updates back to it duplicates content.
Only the format’s own content is saved to the file. The room keeps every shared type it is given, but what reaches disk is what the file’s editor writes. For a format that opens in the text editor - any extension Arg does not know - that is the Y.Text named content, which holds the file’s text. Two rules follow:
- Data that must survive belongs in the file. For a custom format, keep it as JSON in
doc.getText("content")and update it through the text, or write it withdocument.write(json, { expectRevision })and let the room pick up the change. - A shared type of your own, such as
doc.getMap("farm"), is shared live but never written to the file, and is not guaranteed to survive the room reloading from the file - which happens after anything writes the file outside the room (an agent, a sync,fs.write). Use it for state that may reset, such as a lobby or a round in progress.
Leaving
onClose fires when the room goes away under the app: room_closed (the file was closed), room_replaced (the editor reconnected into a new room - call collaborate() again) or document_changed (the app was moved to a different file). close() withdraws your presence fields and stops the updates.
Where it works
Web and the desktop app support rooms in.html, .tsx and .jsx apps. The iOS and Android apps do not yet: collaborate() reports unsupported_capability there, so keep the solo fallback. A published Site has no document and no room.
Everything in document is listed in the SDK reference; reading and writing the document without a room is covered in Apps inside Arg.