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 c.varsc.dbtext_labels, 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 SQLite database 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";
import { db } from "rivetkit/db";

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

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

export const cursorRoom = actor({
	// Text labels belong to the room, so they are saved in SQLite.
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS text_labels (
					id TEXT PRIMARY KEY,
					user_id TEXT NOT NULL,
					text TEXT NOT NULL,
					x REAL NOT NULL,
					y REAL NOT NULL,
					timestamp INTEGER NOT NULL
				)
			`);
		},
	}),
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	// Cursors belong to live connections, so they stay in memory keyed by connection ID.
	createVars: () => ({
		cursors: new Map<string, CursorPosition>(),
	}),

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

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

			// Insert a new text label or replace the existing one with the same ID
			await c.db.execute(
				"INSERT OR REPLACE INTO text_labels (id, user_id, text, x, y, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
				textLabel.id,
				textLabel.userId,
				textLabel.text,
				textLabel.x,
				textLabel.y,
				textLabel.timestamp,
			);

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

		// Remove text from the canvas
		removeText: async (c, id: string) => {
			await c.db.execute("DELETE FROM text_labels WHERE id = ?", id);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: async (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const cursor of c.vars.cursors.values()) {
				cursors[cursor.userId] = cursor;
			}
			const textLabels = (await c.db.execute(
				"SELECT id, user_id AS userId, text, x, y, timestamp FROM text_labels ORDER BY timestamp",
			)) as TextLabel[];
			return {
				cursors,
				textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		const cursor = c.vars.cursors.get(conn.id);
		c.vars.cursors.delete(conn.id);
		if (cursor) {
			c.broadcast("cursorRemoved", cursor);
		}
	},
});

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

registry.start();
  • The text_labels table lives in the Actor’s SQLite database. Every write is saved before the action returns, so labels are still there after everyone leaves and the Actor sleeps or restarts.
  • cursors is a map in vars, keyed by connection ID. Each connection gets its own entry, and onDisconnect deletes it when the connection closes. Vars are never saved, and presence only means something while the user is connected, so 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";
import { db } from "rivetkit/db";

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

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

export const cursorRoom = actor({
	// Text labels belong to the room, so they are saved in SQLite.
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS text_labels (
					id TEXT PRIMARY KEY,
					user_id TEXT NOT NULL,
					text TEXT NOT NULL,
					x REAL NOT NULL,
					y REAL NOT NULL,
					timestamp INTEGER NOT NULL
				)
			`);
		},
	}),
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	// Cursors belong to live connections, so they stay in memory keyed by connection ID.
	createVars: () => ({
		cursors: new Map<string, CursorPosition>(),
	}),

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

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

			// Insert a new text label or replace the existing one with the same ID
			await c.db.execute(
				"INSERT OR REPLACE INTO text_labels (id, user_id, text, x, y, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
				textLabel.id,
				textLabel.userId,
				textLabel.text,
				textLabel.x,
				textLabel.y,
				textLabel.timestamp,
			);

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

		// Remove text from the canvas
		removeText: async (c, id: string) => {
			await c.db.execute("DELETE FROM text_labels WHERE id = ?", id);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: async (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const cursor of c.vars.cursors.values()) {
				cursors[cursor.userId] = cursor;
			}
			const textLabels = (await c.db.execute(
				"SELECT id, user_id AS userId, text, x, y, timestamp FROM text_labels ORDER BY timestamp",
			)) as TextLabel[];
			return {
				cursors,
				textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		const cursor = c.vars.cursors.get(conn.id);
		c.vars.cursors.delete(conn.id);
		if (cursor) {
			c.broadcast("cursorRemoved", cursor);
		}
	},
});

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

registry.start();
  • It stores the cursor in c.vars.cursors under c.conn.id, the calling connection’s ID, 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 per connection in c.vars 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";
import { db } from "rivetkit/db";

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

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

export const cursorRoom = actor({
	// Text labels belong to the room, so they are saved in SQLite.
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS text_labels (
					id TEXT PRIMARY KEY,
					user_id TEXT NOT NULL,
					text TEXT NOT NULL,
					x REAL NOT NULL,
					y REAL NOT NULL,
					timestamp INTEGER NOT NULL
				)
			`);
		},
	}),
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	// Cursors belong to live connections, so they stay in memory keyed by connection ID.
	createVars: () => ({
		cursors: new Map<string, CursorPosition>(),
	}),

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

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

			// Insert a new text label or replace the existing one with the same ID
			await c.db.execute(
				"INSERT OR REPLACE INTO text_labels (id, user_id, text, x, y, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
				textLabel.id,
				textLabel.userId,
				textLabel.text,
				textLabel.x,
				textLabel.y,
				textLabel.timestamp,
			);

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

		// Remove text from the canvas
		removeText: async (c, id: string) => {
			await c.db.execute("DELETE FROM text_labels WHERE id = ?", id);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: async (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const cursor of c.vars.cursors.values()) {
				cursors[cursor.userId] = cursor;
			}
			const textLabels = (await c.db.execute(
				"SELECT id, user_id AS userId, text, x, y, timestamp FROM text_labels ORDER BY timestamp",
			)) as TextLabel[];
			return {
				cursors,
				textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		const cursor = c.vars.cursors.get(conn.id);
		c.vars.cursors.delete(conn.id);
		if (cursor) {
			c.broadcast("cursorRemoved", 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.vars.cursors, which only holds live connections, instead of reading a stored list. The answer always matches who is connected. It reads the labels from SQLite.
  • Leave: onDisconnect runs when a connection closes and removes that connection’s cursor from c.vars.cursors and broadcasts cursorRemoved with it. 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";
import { db } from "rivetkit/db";

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

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

export const cursorRoom = actor({
	// Text labels belong to the room, so they are saved in SQLite.
	db: db({
		onMigrate: async (db) => {
			await db.execute(`
				CREATE TABLE IF NOT EXISTS text_labels (
					id TEXT PRIMARY KEY,
					user_id TEXT NOT NULL,
					text TEXT NOT NULL,
					x REAL NOT NULL,
					y REAL NOT NULL,
					timestamp INTEGER NOT NULL
				)
			`);
		},
	}),
	events: {
		cursorMoved: event<CursorPosition>(),
		cursorRemoved: event<CursorPosition>(),
		textUpdated: event<TextLabel>(),
		textRemoved: event<string>(),
	},

	// Cursors belong to live connections, so they stay in memory keyed by connection ID.
	createVars: () => ({
		cursors: new Map<string, CursorPosition>(),
	}),

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

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

			// Insert a new text label or replace the existing one with the same ID
			await c.db.execute(
				"INSERT OR REPLACE INTO text_labels (id, user_id, text, x, y, timestamp) VALUES (?, ?, ?, ?, ?, ?)",
				textLabel.id,
				textLabel.userId,
				textLabel.text,
				textLabel.x,
				textLabel.y,
				textLabel.timestamp,
			);

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

		// Remove text from the canvas
		removeText: async (c, id: string) => {
			await c.db.execute("DELETE FROM text_labels WHERE id = ?", id);
			c.broadcast("textRemoved", id);
		},

		// Get all room state (cursors and text labels)
		getRoomState: async (c) => {
			const cursors: Record<string, CursorPosition> = {};
			for (const cursor of c.vars.cursors.values()) {
				cursors[cursor.userId] = cursor;
			}
			const textLabels = (await c.db.execute(
				"SELECT id, user_id AS userId, text, x, y, timestamp FROM text_labels ORDER BY timestamp",
			)) as TextLabel[];
			return {
				cursors,
				textLabels,
			};
		},
	},

	onDisconnect: (c, conn) => {
		const cursor = c.vars.cursors.get(conn.id);
		c.vars.cursors.delete(conn.id);
		if (cursor) {
			c.broadcast("cursorRemoved", 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 the text_labels table and broadcasts textUpdated. The client calls it on every keystroke, so others watch the text being typed.
  • removeText deletes the label’s row 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 table 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, onConnect and onDisconnect, 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.