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 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.textLabelsis persistent state. It is saved automatically and is still there after everyone leaves and the Actor sleeps or restarts.connState.cursoris 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.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";
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 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 in
connStateand 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
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.conns.values(), the live connections, instead of reading a stored list. The answer always matches who is connected.- Leave:
onDisconnectruns when a connection closes and broadcastscursorRemovedwith that connection’s last cursor. 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";
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();
updateTextinserts or replaces a label byidinc.state.textLabelsand broadcaststextUpdated. The client calls it on every keystroke, so others watch the text being typed.removeTextfilters the label out 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 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 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,
createConnState, 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.