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.
What we’ll build:
- A
cursorRoomActor 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_labelstable 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. cursorsis a map in vars, keyed by connection ID. Each connection gets its own entry, andonDisconnectdeletes 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.eventsdeclares the four typed events the room broadcasts. The React client gets the same payload types throughtypeof 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.cursorsunderc.conn.id, the calling connection’s ID, then broadcastscursorMovedto 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.varsand 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
getRoomStateonce after it connects to load the cursors and labels already in the room. Other users first see the newcomer on their firstcursorMoved. getRoomStatebuilds the cursor map fromc.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:
onDisconnectruns when a connection closes and removes that connection’s cursor fromc.vars.cursorsand broadcastscursorRemovedwith it. Clients delete it from their map, so the cursor disappears as soon as the tab closes. See Lifecycle for whenonDisconnectruns.
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();
updateTextinserts or replaces a label byidin thetext_labelstable and broadcaststextUpdated. The client calls it on every keystroke, so others watch the text being typed.removeTextdeletes the label’s row and broadcaststextRemovedwith 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 togeneral, and changing it reconnects to a different Actor.- Once
cursorRoom.connectionis ready, the client callsgetRoomStateto seedcursorsandtextLabels. From then on,useEventhandlers applycursorMoved,cursorRemoved,textUpdated, andtextRemovedas they arrive. handleMouseMoveconverts the mouse position to canvas space and callsupdateCursor. This is where a client throttle would go.- The client draws its own cursor from the
cursorMovedecho, 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,
onConnectandonDisconnect, andonBeforeConnectfor 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.