# Live Cursors and Presence

> Source: `src/content/cookbook/live-cursors.mdx`
> Canonical URL: https://rivet.dev/cookbook/live-cursors
> Description: Live cursors and multiplayer presence with Rivet Actors: per-connection cursor state, realtime updates over events or raw WebSockets, and throttling.

---
Patterns for building live cursors, multiplayer presence, and realtime cursor sharing with RivetKit. One room actor fans cursor positions out to every connected client, keyed per room with [actor keys](/docs/actors/keys).

## Starter Code

Start with one of the two working variants on GitHub. Both implement the same collaborative cursor canvas with persistent text labels; they differ only in transport.

| Variant | Starter Code | Transport | Presence Storage |
| --- | --- | --- | --- |
| `cursors` | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/cursors) | Typed [actions](/docs/actors/actions) and [events](/docs/actors/events) over the RivetKit connection | `connState` per connection |
| `cursors-raw-websocket` | [GitHub](https://github.com/rivet-dev/rivet/tree/main/examples/cursors-raw-websocket) | Raw [`onWebSocket` handler](/docs/actors/websocket-handler) with a custom JSON message protocol | Socket map in `createVars` |

Use `cursors` by default: typed actions, typed events, and automatic connection tracking cover most apps with less code. Use `cursors-raw-websocket` when you need full control of the wire format, for example a custom JSON or binary protocol, or clients that do not use the RivetKit client library.

## Connection State vs Persistent State

Presence is ephemeral by definition. A cursor position is only meaningful while its connection is alive, so it belongs in per-connection storage, not in persistent actor state. Persistent state is reserved for data that must survive disconnects and actor restarts.

| Data | Where It Lives | Why |
| --- | --- | --- |
| Cursor position | `connState` (`cursors`) or the `createVars` socket map (`cursors-raw-websocket`) | Scoped to one connection and discarded with it. Stale presence cannot accumulate in storage. |
| Text labels (`textLabels`) | Persistent actor `state` in both variants | Canvas content must survive disconnects and actor restarts. |

In the `cursors` variant, `updateCursor` writes `c.conn.state.cursor` and `getRoomState` rebuilds the presence snapshot by iterating `c.conns.values()`, so the cursor map is always derived from live connections rather than stored. See [Connections](/docs/actors/connections) for `connState` and [State](/docs/actors/state) for persistence semantics.

## Presence Lifecycle

- **Join**: The `cursors-raw-websocket` variant pushes an `init` message with the current `{ cursors, textLabels }` snapshot as soon as a socket connects. The `cursors` variant has no explicit join broadcast; the client calls the `getRoomState` action once after connecting to seed its local maps, and peers first see a new user on that user's first `cursorMoved` broadcast.
- **Move**: Every `updateCursor` call writes the connection's presence entry, then broadcasts `cursorMoved` to all connections, including the sender.
- **Leave**: The `cursors` variant handles leave in `onDisconnect`, broadcasting `cursorRemoved` with the connection's last cursor. The raw variant does the same from the socket `close` listener, then deletes the session from the `vars.websockets` map. Clients delete that user from their local cursor map, so stale cursors disappear the moment a tab closes.

See [Lifecycle](/docs/actors/lifecycle) for `onDisconnect` and `createVars`.

## Update Throttling

Neither example throttles. Both frontends send a cursor update on every raw `mousemove` event with no debounce or interval cap. That is fine for a demo, but a fast mouse on a high-refresh display can emit hundreds of events per second per user. The patterns below are recommended production hardening on top of the starter code, not something the examples implement.

| Layer | Pattern | Guidance |
| --- | --- | --- |
| Client (smoothness) | Throttle to 20-30Hz | Sample the latest pointer position every 33-50ms and send only that. Drop intermediate moves, but always flush the final position so cursors settle at the true location. Interpolate between received positions on the rendering side. |
| Server (enforcement) | Per-connection rate limit | Track the last accepted update timestamp per connection and drop or coalesce updates arriving faster than your cap. Client throttles are cooperative; the actor is the enforcement boundary. |

## Actors

- **Key**: `cursorRoom[roomId]` (the frontend defaults `roomId` to `"general"`)
- **Responsibility**: Holds per-connection cursor presence in `connState`, persists shared text labels in actor state, and broadcasts cursor and text updates to all connections.
- **Actions**
  - `updateCursor`
  - `updateText`
  - `removeText`
  - `getRoomState`
- **Events**
  - `cursorMoved`
  - `cursorRemoved`
  - `textUpdated`
  - `textRemoved`
- **Queues**
  - None
- **State**
  - JSON
  - `textLabels` (persistent)
  - `connState.cursor` per connection (ephemeral)

- **Key**: `cursorRoom[roomId]` (resolved via `client.cursorRoom.getOrCreate(roomId)`)
- **Responsibility**: Exposes a raw WebSocket endpoint, tracks live sockets and their cursors in a `createVars` map keyed by a `sessionId` query parameter, persists text labels, and manually fans JSON frames out to every socket.
- **Actions**
  - `getOrCreate` (stub returning `{ status: "ok" }`; the frontend resolves the actor ID with the client handle's `getOrCreate(roomId).resolve()`, which creates the actor without dispatching this action)
  - `getRoomState`
- **Queues**
  - None
- **State**
  - JSON
  - `textLabels` (persistent)
  - `vars.websockets` map of `sessionId` to socket and cursor (in-memory, lost on restart)

The raw variant defines no RivetKit events. Its message names are `type` fields on raw JSON frames:

| Direction | Message `type` | Payload |
| --- | --- | --- |
| Client to server | `updateCursor` | `{ userId, x, y }` |
| Client to server | `updateText` | `{ id, userId, text, x, y }` |
| Client to server | `removeText` | `{ id }` |
| Server to client | `init` | `{ cursors, textLabels }` snapshot on connect |
| Server to client | `cursorMoved`, `textUpdated`, `textRemoved`, `cursorRemoved` | The corresponding cursor, label, or ID payload |

## Lifecycle

### cursors (Actions + Events)

```mermaid
sequenceDiagram
	participant A as Client A
	participant R as cursorRoom
	participant B as Other Clients

	A->>R: connect via useActor (cursorRoom[roomId])
	A->>R: getRoomState()
	R-->>A: {cursors, textLabels}
	loop every mouse move
		A->>R: updateCursor(userId, x, y)
		Note over R: write c.conn.state.cursor
		R-->>B: cursorMoved (broadcast)
	end
	A->>R: updateText(id, userId, text, x, y)
	Note over R: upsert persistent state.textLabels
	R-->>B: textUpdated (broadcast)
	Note over A: tab closes
	Note over R: onDisconnect reads conn.state.cursor
	R-->>B: cursorRemoved (broadcast)
```

### cursors-raw-websocket

```mermaid
sequenceDiagram
	participant A as Client A
	participant R as cursorRoom
	participant B as Other Clients

	A->>R: getOrCreate(roomId).resolve()
	R-->>A: actorId
	A->>R: open WebSocket /gateway/{actorId}/websocket?sessionId=...
	Note over R: close 1008 if sessionId is missing
	Note over R: store socket in vars.websockets
	R-->>A: init {cursors, textLabels}
	loop every mouse move
		A->>R: {type: "updateCursor"} frame
		Note over R: update session cursor in vars
		R-->>B: cursorMoved frame
	end
	Note over A: socket closes
	R-->>B: cursorRemoved frame
	Note over R: delete session from vars.websockets
```

## Security Checklist

Both examples ship without authentication so the presence pattern stays readable. Everything below is recommended hardening for production, not behavior the examples implement.

- **Identity**: Bind presence identity to the connection (`c.conn.id` in the actions variant, a server-generated session ID in the raw variant). Never trust a client-supplied `userId`; in the examples it is a random client-generated string, so any client can impersonate or remove any cursor.
- **Authorization**: Authorize label mutations by owner. In the examples, `updateText` accepts arbitrary `id` and `userId` arguments and `removeText` accepts an arbitrary `id`, so any client can edit or delete any label.
- **Input validation**: Clamp `x` and `y` to canvas bounds, cap text label length, and cap the total `textLabels` count so persistent state cannot grow unbounded.
- **Rate limiting**: Enforce a per-connection cap on `updateCursor` (for example 30Hz) and on label writes, as described in [Update Throttling](#update-throttling).
- **Protocol strictness (raw variant)**: Validate message shape before use and close the socket on malformed JSON instead of logging and continuing. Reject duplicate `sessionId` values rather than silently overwriting another session's socket entry.

_Source doc path: /cookbook/live-cursors_
