> ## Documentation Index
> Fetch the complete documentation index at: https://developers.arg.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Multiplayer apps

> Show everyone running an .html, .tsx or .jsx app to each other - live presence, and shared state through a file - with @arg-ai/sdk's document room.

Every file Arg can edit together has a **room**: the live channel its editor uses to show you who else is there and to merge everyone's edits. An app can join that room through `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:

| | Presence (`session.awareness`) | Shared state (Yjs updates) |
| - | - | - |
| What it is | One small JSON object per viewer, replaced as it changes | A CRDT document every viewer converges on |
| Lifetime | Gone when that viewer closes the file or disconnects | Kept by the room; only the format's own content reaches the file |
| Good for | Position, cursor, selection, typing, emotes, "who is here" | Data everyone should see and keep |
| Needs `yjs` | No | Yes - your app brings its own copy |

## Getting a room

A room belongs to a **file**, and an app reaches the room of its `document`. There are two ways to give an app one, and they decide who is in the room and what it can hold:

| | Launched from its `.app` | Opened on a file of a registered type |
| - | - | - |
| `document` is | The `.app` launcher, read-only | The file, e.g. `team.farm` |
| Who is in a room | Everyone running the app from that launcher | Everyone with that file open |
| Presence | Yes | Yes |
| Shared Yjs state | No - `session.readOnly` is `true` | Yes, for viewers who may edit the file |
| Saved data | None - use `fs` for anything that must last | The file's own content |

### 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:

```json /apps/farm.app theme={null}
{
  "version": 2,
  "name": "Farm",
  "icon": "<svg viewBox=\"0 0 24 24\">...</svg>",
  "entry": { "kind": "file", "id": null, "path": "/apps/farm.html" },
  "file_types": ["farm"],
  "permissions": { "files": { "access": "readwrite", "scope": "folder" } }
}
```

Now `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

```ts theme={null}
import { auth, document } from "@arg-ai/sdk";

const active = await document.current();
if (!active) return runSolo(); // an .html opened directly, or outside Arg

const session = await document.collaborate();
const me = session.awareness.clientId;
```

Wrap the import in a dynamic `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`:

```ts theme={null}
type ArgAwarenessState = {
  clientId: number;
  state: {
    user?: {
      userId: string;
      name: string;
      color: string;
      avatarUrl?: string;
      avatarEmoji?: string;
    };
    [field: string]: unknown; // whatever each client published
  } | null;
};
```

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

```ts theme={null}
// Publish this viewer's player when it changes (throttled - see below).
session.awareness.setLocalStateField("player", { x, z, facing, emote });

// Draw everyone else.
const render = (states) => {
  for (const { clientId, state } of states) {
    if (clientId === me) continue; // your own entry is in the list
    if (!state?.player) continue; // open in the file's editor, not the game
    drawPlayer(
      clientId,
      state.user?.name ?? "guest",
      state.user?.color,
      state.player,
    );
  }
};
render(session.awareness.states());
session.awareness.onChange(render);
```

Four things to get right:

* **Your own entry is in `states()`.** Skip the one whose `clientId` is `session.awareness.clientId`.
* **Not everyone in the room is running your app.** Someone with the same file open in its regular editor has a `user` and nothing else. Key what you draw on your own field.
* **Your fields are merged into the viewer's entry, never replacing it.** `setLocalState({ player })` sets `player` and leaves the rest; `setLocalState(null)` clears only the fields your app published. `user` is 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.

When a viewer closes the file, loses access or disconnects, their entry disappears and `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:

```ts theme={null}
import * as Y from "yjs";

const doc = new Y.Doc();
Y.applyUpdate(doc, session.initialState, session);
session.onUpdate((update) => Y.applyUpdate(doc, update, session));
doc.on("update", (update, origin) => {
  if (origin !== session) session.update(update);
});
```

Apply `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 with `document.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.

For a format Arg has its own editor for, the shared types belong to that editor - a kanban board's lanes, a document's text. Read that editor's shape rather than inventing a new one, or your writes will not appear in it and will not be saved.

## Leaving

```ts theme={null}
session.onClose((reason) => showSolo(reason));
window.addEventListener("pagehide", () => session.close());
```

`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](/guides/sdk/hosted-sites) has no document and no room.

Everything in `document` is listed in the [SDK reference](/guides/sdk/reference); reading and writing the document without a room is covered in [Apps inside Arg](/guides/sdk/embedded-apps#the-active-document).
