Skip to main content
Realtime

Live Cursors and Presence

Build live cursors and presence for a shared canvas, where one room Actor tracks who is connected and broadcasts every cursor move.

See the full example on GitHub. View on GitHub
Browser Amoves the mouseBrowser Bdraws cursorscursorRoomcursors in connStatec.statetextLabels, savedgetRoomState on join1updateCursor2cursorMoved to all3cursorRemoved when A leaves4updateTextOne per roomRivet ActorCallReply

What we’ll build:

  • A cursorRoom Actor per room, keyed by the room name, that every browser in the room connects to.
  • Cursor presence stored per connection, so a cursor disappears the moment its tab closes.
  • Text labels on the canvas that are saved in the Actor’s state and survive restarts.
  • A React client that sends its cursor on every mouse move and draws everyone else’s.

Install

npm add rivetkit @rivetkit/react

Create the room Actor

Each room is its own cursorRoom Actor. It keeps two kinds of data: the text labels, which belong to the room, and the cursors, which belong to whoever is connected right now:

import { actor, event, setup } from "rivetkit";

export interface CursorPosition {
	userId: string;
	x: number;
	y: number;
	timestamp: number;
}

export interface TextLabel {
	id: string;
	userId: string;
	text: string;
	x: number;
	y: number;
	timestamp: number;
}

export const cursorRoom = actor({
	state: {
		textLabels: [] as TextLabel[],
	},
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	connState: {
		cursor: null as CursorPosition | null,
	},

	actions: {
		// Update cursor position
		updateCursor: (c, userId: string, x: number, y: number) => {
			const cursor: CursorPosition = {
				userId,
				x,
				y,
				timestamp: Date.now(),
			};
			c.conn.state.cursor = cursor;
			c.broadcast("cursorMoved", cursor);
			return cursor;
		},

		// Update text on the canvas (creates or updates)
		updateText: (
			c,
			id: string,
			userId: string,
			text: string,
			x: number,
			y: number,
		) => {
			const textLabel: TextLabel = {
				id,
				userId,
				text,
				x,
				y,
				timestamp: Date.now(),
			};

			// Find and update existing text label or add new one
			const existingIndex = c.state.textLabels.findIndex(
				(label) => label.id === id,
			);
			if (existingIndex >= 0) {
				c.state.textLabels[existingIndex] = textLabel;
			} else {
				c.state.textLabels.push(textLabel);
			}

			c.broadcast("textUpdated", textLabel);
			return textLabel;
		},

		// Remove text from the canvas
		removeText: (c, id: string) => {
			c.state.textLabels = c.state.textLabels.filter(
				(label) => label.id !== id,
			);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const conn of c.conns.values()) {
				if (conn.state.cursor) {
					cursors[conn.state.cursor.userId] = conn.state.cursor;
				}
			}
			return {
				cursors,
				textLabels: c.state.textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		if (conn.state.cursor) {
			c.broadcast("cursorRemoved", conn.state.cursor);
		}
	},
});

// Register actors and start the server: https://rivet.dev/docs/setup
export const registry = setup({
	use: { cursorRoom },
});

registry.start();
  • state.textLabels is persistent state. It is saved automatically and is still there after everyone leaves and the Actor sleeps or restarts.
  • connState.cursor is connection state. Each connection gets its own copy, and it is thrown away when the connection closes. Presence only means something while the user is connected, so keeping it here means stale cursors can never pile up in storage.
  • events declares the four typed events the room broadcasts. The React client gets the same payload types through typeof registry.
  • The client picks the room with the key [roomId]. Two tabs with the same room name reach the same Actor, and different rooms never share cursors or labels.

Broadcast cursor moves

updateCursor is the hot path. Every browser calls it on every mouse move:

import { actor, event, setup } from "rivetkit";

export interface CursorPosition {
	userId: string;
	x: number;
	y: number;
	timestamp: number;
}

export interface TextLabel {
	id: string;
	userId: string;
	text: string;
	x: number;
	y: number;
	timestamp: number;
}

export const cursorRoom = actor({
	state: {
		textLabels: [] as TextLabel[],
	},
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	connState: {
		cursor: null as CursorPosition | null,
	},

	actions: {
		// Update cursor position
		updateCursor: (c, userId: string, x: number, y: number) => {
			const cursor: CursorPosition = {
				userId,
				x,
				y,
				timestamp: Date.now(),
			};
			c.conn.state.cursor = cursor;
			c.broadcast("cursorMoved", cursor);
			return cursor;
		},

		// Update text on the canvas (creates or updates)
		updateText: (
			c,
			id: string,
			userId: string,
			text: string,
			x: number,
			y: number,
		) => {
			const textLabel: TextLabel = {
				id,
				userId,
				text,
				x,
				y,
				timestamp: Date.now(),
			};

			// Find and update existing text label or add new one
			const existingIndex = c.state.textLabels.findIndex(
				(label) => label.id === id,
			);
			if (existingIndex >= 0) {
				c.state.textLabels[existingIndex] = textLabel;
			} else {
				c.state.textLabels.push(textLabel);
			}

			c.broadcast("textUpdated", textLabel);
			return textLabel;
		},

		// Remove text from the canvas
		removeText: (c, id: string) => {
			c.state.textLabels = c.state.textLabels.filter(
				(label) => label.id !== id,
			);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const conn of c.conns.values()) {
				if (conn.state.cursor) {
					cursors[conn.state.cursor.userId] = conn.state.cursor;
				}
			}
			return {
				cursors,
				textLabels: c.state.textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		if (conn.state.cursor) {
			c.broadcast("cursorRemoved", conn.state.cursor);
		}
	},
});

// Register actors and start the server: https://rivet.dev/docs/setup
export const registry = setup({
	use: { cursorRoom },
});

registry.start();
  • It writes the cursor to c.conn.state, the calling connection’s own state, then broadcasts cursorMoved to every connection in the room, including the sender.
  • The position is in a fixed 1920x1080 canvas space, not screen pixels. Each client scales the canvas to its window, so cursors line up across screen sizes.

The example sends an update on every raw mousemove and the Actor accepts all of them. A fast mouse on a high refresh rate display can send hundreds of updates per second per user. In production, throttle on both sides:

  • Client: send the latest position every 33 to 50ms (20 to 30Hz) and drop the moves in between. Always send the final position so cursors settle where the mouse stopped, and interpolate between received positions when drawing.
  • Actor: keep the time of the last accepted update in connState and drop updates that arrive faster than your cap. A client throttle is only a courtesy, so the Actor is where the limit is enforced.

userId comes from the client. In the example it is a random string the browser makes up, so any client can send moves as another user. In production, key presence by c.conn.id or by a user id from authentication, and clamp x and y to the canvas bounds.

Track who joins and leaves

Presence has three moments: join, move, and leave. Move is updateCursor. Join and leave are these two:

import { actor, event, setup } from "rivetkit";

export interface CursorPosition {
	userId: string;
	x: number;
	y: number;
	timestamp: number;
}

export interface TextLabel {
	id: string;
	userId: string;
	text: string;
	x: number;
	y: number;
	timestamp: number;
}

export const cursorRoom = actor({
	state: {
		textLabels: [] as TextLabel[],
	},
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	connState: {
		cursor: null as CursorPosition | null,
	},

	actions: {
		// Update cursor position
		updateCursor: (c, userId: string, x: number, y: number) => {
			const cursor: CursorPosition = {
				userId,
				x,
				y,
				timestamp: Date.now(),
			};
			c.conn.state.cursor = cursor;
			c.broadcast("cursorMoved", cursor);
			return cursor;
		},

		// Update text on the canvas (creates or updates)
		updateText: (
			c,
			id: string,
			userId: string,
			text: string,
			x: number,
			y: number,
		) => {
			const textLabel: TextLabel = {
				id,
				userId,
				text,
				x,
				y,
				timestamp: Date.now(),
			};

			// Find and update existing text label or add new one
			const existingIndex = c.state.textLabels.findIndex(
				(label) => label.id === id,
			);
			if (existingIndex >= 0) {
				c.state.textLabels[existingIndex] = textLabel;
			} else {
				c.state.textLabels.push(textLabel);
			}

			c.broadcast("textUpdated", textLabel);
			return textLabel;
		},

		// Remove text from the canvas
		removeText: (c, id: string) => {
			c.state.textLabels = c.state.textLabels.filter(
				(label) => label.id !== id,
			);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const conn of c.conns.values()) {
				if (conn.state.cursor) {
					cursors[conn.state.cursor.userId] = conn.state.cursor;
				}
			}
			return {
				cursors,
				textLabels: c.state.textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		if (conn.state.cursor) {
			c.broadcast("cursorRemoved", conn.state.cursor);
		}
	},
});

// Register actors and start the server: https://rivet.dev/docs/setup
export const registry = setup({
	use: { cursorRoom },
});

registry.start();
  • Join: there is no join broadcast. A new client calls getRoomState once after it connects to load the cursors and labels already in the room. Other users first see the newcomer on their first cursorMoved.
  • getRoomState builds the cursor map from c.conns.values(), the live connections, instead of reading a stored list. The answer always matches who is connected.
  • Leave: onDisconnect runs when a connection closes and broadcasts cursorRemoved with that connection’s last cursor. Clients delete it from their map, so the cursor disappears as soon as the tab closes. See Lifecycle for when onDisconnect runs.

Save text labels

Clicking the canvas places a text label that everyone sees and that stays after the page reloads:

import { actor, event, setup } from "rivetkit";

export interface CursorPosition {
	userId: string;
	x: number;
	y: number;
	timestamp: number;
}

export interface TextLabel {
	id: string;
	userId: string;
	text: string;
	x: number;
	y: number;
	timestamp: number;
}

export const cursorRoom = actor({
	state: {
		textLabels: [] as TextLabel[],
	},
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	connState: {
		cursor: null as CursorPosition | null,
	},

	actions: {
		// Update cursor position
		updateCursor: (c, userId: string, x: number, y: number) => {
			const cursor: CursorPosition = {
				userId,
				x,
				y,
				timestamp: Date.now(),
			};
			c.conn.state.cursor = cursor;
			c.broadcast("cursorMoved", cursor);
			return cursor;
		},

		// Update text on the canvas (creates or updates)
		updateText: (
			c,
			id: string,
			userId: string,
			text: string,
			x: number,
			y: number,
		) => {
			const textLabel: TextLabel = {
				id,
				userId,
				text,
				x,
				y,
				timestamp: Date.now(),
			};

			// Find and update existing text label or add new one
			const existingIndex = c.state.textLabels.findIndex(
				(label) => label.id === id,
			);
			if (existingIndex >= 0) {
				c.state.textLabels[existingIndex] = textLabel;
			} else {
				c.state.textLabels.push(textLabel);
			}

			c.broadcast("textUpdated", textLabel);
			return textLabel;
		},

		// Remove text from the canvas
		removeText: (c, id: string) => {
			c.state.textLabels = c.state.textLabels.filter(
				(label) => label.id !== id,
			);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const conn of c.conns.values()) {
				if (conn.state.cursor) {
					cursors[conn.state.cursor.userId] = conn.state.cursor;
				}
			}
			return {
				cursors,
				textLabels: c.state.textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		if (conn.state.cursor) {
			c.broadcast("cursorRemoved", conn.state.cursor);
		}
	},
});

// Register actors and start the server: https://rivet.dev/docs/setup
export const registry = setup({
	use: { cursorRoom },
});

registry.start();
  • updateText inserts or replaces a label by id in c.state.textLabels and broadcasts textUpdated. The client calls it on every keystroke, so others watch the text being typed.
  • removeText filters the label out and broadcasts textRemoved with its id. The client calls it when the user presses Escape or finishes with an empty label.

Any client can edit or remove any label, because updateText trusts the id and userId it is given and removeText trusts the id. In production, check that the caller owns the label, cap the text length, and cap the number of labels so the persistent state cannot grow without bound.

Connect the browser

The React client connects with useActor, loads the room once, then keeps its maps up to date from events:

const cursorRoom = useActor({
	name: "cursorRoom",
	key: [roomId],
});

// Calculate scale factor to fit canvas in viewport
useEffect(() => {
	const updateScale = () => {
		if (!containerRef.current) return;

		const containerWidth = containerRef.current.clientWidth;
		const containerHeight = containerRef.current.clientHeight;

		// Calculate scale to fit canvas while maintaining aspect ratio
		const scaleX = containerWidth / CANVAS_WIDTH;
		const scaleY = containerHeight / CANVAS_HEIGHT;
		const newScale = Math.min(scaleX, scaleY);

		setScale(newScale);
	};

	updateScale();
	window.addEventListener("resize", updateScale);
	return () => window.removeEventListener("resize", updateScale);
}, []);

// Load initial state
useEffect(() => {
	if (cursorRoom.connection) {
		cursorRoom.connection
			.getRoomState()
			.then((state) => {
				setCursors(state.cursors);
				setTextLabels(state.textLabels);
			})
			.catch((error) => {
				console.error("error loading room state", error);
			});
	}
}, [cursorRoom.connection]);

// Listen for cursor movements
cursorRoom.useEvent("cursorMoved", (cursor: CursorPosition) => {
	setCursors((prev) => ({
		...prev,
		[cursor.userId]: cursor,
	}));
});

// Listen for text updates
cursorRoom.useEvent("textUpdated", (label: TextLabel) => {
	setTextLabels((prev) => {
		const existingIndex = prev.findIndex((l) => l.id === label.id);
		if (existingIndex >= 0) {
			const newLabels = [...prev];
			newLabels[existingIndex] = label;
			return newLabels;
		} else {
			return [...prev, label];
		}
	});
});

// Listen for text removal
cursorRoom.useEvent("textRemoved", (id: string) => {
	setTextLabels((prev) => prev.filter((label) => label.id !== id));
});

// Listen for cursor removal (when connection closes)
cursorRoom.useEvent("cursorRemoved", (cursor: CursorPosition) => {
	setCursors((prev) => {
		const newCursors = { ...prev };
		delete newCursors[cursor.userId];
		return newCursors;
	});
});

// Convert screen coordinates to canvas coordinates
const screenToCanvas = (screenX: number, screenY: number) => {
	if (!canvasRef.current) return { x: 0, y: 0 };

	const rect = canvasRef.current.getBoundingClientRect();
	const x = (screenX - rect.left) / scale;
	const y = (screenY - rect.top) / scale;

	return { x, y };
};

// Handle mouse movement on canvas
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
	if (cursorRoom.connection && canvasRef.current) {
		const { x, y } = screenToCanvas(e.clientX, e.clientY);
		cursorRoom.connection.updateCursor(userId, x, y);
	}
};
  • useActor({ name: "cursorRoom", key: [roomId] }) connects to the room’s Actor and creates it if it does not exist yet. The room input defaults to general, and changing it reconnects to a different Actor.
  • Once cursorRoom.connection is ready, the client calls getRoomState to seed cursors and textLabels. From then on, useEvent handlers apply cursorMoved, cursorRemoved, textUpdated, and textRemoved as they arrive.
  • handleMouseMove converts the mouse position to canvas space and calls updateCursor. This is where a client throttle would go.
  • The client draws its own cursor from the cursorMoved echo, labeled “you”, so what a user sees is what everyone else sees.

Try it

git clone https://github.com/rivet-dev/rivet.git
cd rivet/examples/cursors
pnpm install
pnpm dev

Open http://localhost:5173 in two windows side by side. Move the mouse in one and its cursor moves in the other. Click the canvas, type, and press Enter to leave a label. Close one window and its cursor disappears from the other. Reload, and the labels are still there. Change the room name to get an empty canvas of your own.

Next steps

  • Connections: connection params, createConnState, and onBeforeConnect for checking who connects.
  • Chat Room: the same one Actor per room pattern with saved message history.
  • WebSocket Handler: send your own message format over a raw WebSocket instead of actions and events.