live-cursors

작성자: rivet-dev

Rivet Actors를 사용한 라이브 커서 및 멀티플레이어 프레즌스: 연결별 커서 상태, 이벤트 또는 원시 WebSocket을 통한 실시간 업데이트, 스로틀링.

npx skills add https://github.com/rivet-dev/skills --skill live-cursors

Live Cursors and Presence

IMPORTANT: Before doing anything, you MUST read BASE_SKILL.md in this skill's directory. It contains essential guidance on debugging, error handling, state management, deployment, and project setup. Those rules and patterns apply to all RivetKit work. Everything below assumes you have already read and understood it.

Working Examples

If you need a reference implementation, read the raw working example code in these templates:

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.

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.

VariantStarter CodeTransportPresence Storage
cursorsGitHubTyped actions and events over the RivetKit connectionconnState per connection
cursors-raw-websocketGitHubRaw onWebSocket handler with a custom JSON message protocolSocket 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.

DataWhere It LivesWhy
Cursor positionconnState (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 variantsCanvas 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 for connState and 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 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.

LayerPatternGuidance
Client (smoothness)Throttle to 20-30HzSample 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 limitTrack 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:

DirectionMessage typePayload
Client to serverupdateCursor{ userId, x, y }
Client to serverupdateText{ id, userId, text, x, y }
Client to serverremoveText{ id }
Server to clientinit{ cursors, textLabels } snapshot on connect
Server to clientcursorMoved, textUpdated, textRemoved, cursorRemovedThe corresponding cursor, label, or ID payload

Lifecycle

cursors (Actions + Events)

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

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

Reference Map

Actors

Cli

Clients

Cookbook

Deploy

General

Self Hosting

rivet-dev의 다른 스킬

ai-agent
rivet-dev
지속적 메모리를 갖춘 AI 에이전트 백엔드 구축: 대화당 하나의 Rivet Actor, 대기열 메시지 처리, 실시간 이벤트로 스트리밍되는 LLM 응답.
official
ai-agent-workspace
rivet-dev
모든 AI 에이전트에게 자신만의 컴퓨터를 부여하세요: 경량 인프로세스 상에서 파일 시스템, 프로세스, 셸, 네트워킹 및 에이전트 세션을 갖춘 영구 작업 공간입니다…
official
chat-room
rivet-dev
Rivet Actors로 실시간 채팅방 백엔드 구축: 방마다 하나의 액터, SQLite 기반 메시지 기록, 모든 연결된 클라이언트에 WebSocket 브로드캐스트.
official
collaborative-text-editor
rivet-dev
Yjs CRDT와 Rivet Actors를 사용하여 협업 텍스트 편집기 백엔드를 구축합니다: 문서별 액터가 동기화 및 인식 업데이트를 중계하고 스냅샷을 유지합니다.
official
cron-jobs
rivet-dev
Rivet Actors를 사용한 내구성 있는 크론 작업: schedule.after 및 schedule.at 타이머는 재시작과 충돌에도 유지되며, 반복 작업 재설정 및 멱등성 핸들러를 지원합니다.
official
per-tenant-database
rivet-dev
멀티 테넌트 데이터 격리를 위해 테넌트당 하나의 Rivet 액터를 사용합니다. 액터 키는 테넌트 ID이므로 각 테넌트는 자체 격리된 데이터셋과 마이그레이션을 갖습니다.
official
rivetkit-client-javascript
rivet-dev
JavaScript 클라이언트로, 무상태 또는 상태 저장 연결을 통해 Rivet Actors에 연결합니다. 브라우저, Node.js 및 Bun 환경을 지원하며, 환경 변수 또는 명시적 구성을 통한 자동 엔드포인트 감지 기능을 제공합니다. 독립적인 요청을 위한 무상태 액션 호출과 실시간 이벤트 구독이 가능한 상태 저장 연결의 두 가지 상호작용 모드를 제공합니다. onRequest 또는 onWebSocket 핸들러를 구현하는 액터를 위한 저수준 HTTP 및 WebSocket 액세스를 포함하며, 복합 배열 기반...
official
rivetkit-client-react
rivet-dev
React 클라이언트로, 훅과 실시간 상태 관리를 통해 Rivet Actors에 연결합니다. createRivetKit()으로 타입이 지정된 훅을 생성하고, useActor()로 키와 선택적 매개변수를 사용하여 액터 인스턴스에 연결합니다. useEvent()로 액터 이벤트를 구독하고, connStatus와 error 상태를 통해 연결 수명 주기를 모니터링합니다. createClient()를 사용하여 상태 비저장 단일 호출, 액터 검색 메서드(get, getOrCreate, create, getForId), 그리고 저수준 HTTP/WebSocket 액세스를 수행합니다. 복합 배열 키를 지원합니다...
official