convex-multiplayer-cursors
Real-time multiplayer cursors (Figma-style) for Convex:
a reusable component (isolated tables and functions, mounted with
app.use) plus a React client with interpolated replay. Room presence with
heartbeat and TTL, positions as sample batches, click ripples and cursor chat
(press /), all in plain TypeScript.

Documentação em português
Why it is cheap
The cost of multiplayer on Convex is defined by WHO each write invalidates.
The component splits three tables by invalidation profile:
sessions | join/leave/name change | roster (cold) |
beats | heartbeat (~20s) | NOBODY |
positions | batch flush (~5/s while moving) | room positions (hot) |
bubbles | while someone types (throttled) | room chat (warm) |
Click ripples get NO table at all: a click travels as a tagged sample
(k: "click") inside the positions batch that already existed, and fires on
the receiver exactly when the interpolated playhead crosses it.
Consequences: heartbeats re-run no query at all; movement invalidates only the
hot positions query; the React roster re-renders only on join/leave. On the
client, positions arrive through watchQuery outside React and go straight to
the DOM in a rAF loop (zero re-renders per movement). Output is single-flight:
at most one mutation in the air, one sample batch every ~200ms ONLY while the
pointer moves.
Playback deliberately lags lagMs (~260ms): there is always a next sample to
interpolate towards, so remote cursors glide instead of teleporting. Cleanup is
twofold: ONE scheduled expire per session (when it fires and a beat has pushed
the deadline, it reschedules itself) plus opportunistic pruning by the room's
live beats.
Install
npm i convex-multiplayer-cursors
Plain TS source, no build step: Convex's esbuild, Vite, and tsc with
moduleResolution: "bundler" consume it directly. On Next.js, add the package
to transpilePackages.
Wiring (3 steps)
- Mount the component in
convex/convex.config.ts:
import { defineApp } from "convex/server";
import cursors from "convex-multiplayer-cursors/convex.config";
const app = defineApp();
app.use(cursors);
export default app;
- Authenticated wrappers in your app. The component never sees
ctx.auth: YOU authenticate, decide the room server-side and pass the
userId (the golden rule of Convex components):
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { components } from "./_generated/api";
import { Cursors, cursorSampleValidator, roomKey } from "convex-multiplayer-cursors/client";
const cursors = new Cursors(components.cursors);
export const sessions = query({
args: { path: v.string() },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("unauthenticated");
return await cursors.listSessions(ctx, roomKey(identity.orgId ?? "global", args.path));
},
});
export const beat = mutation({
args: { path: v.string(), sessionId: v.string() },
returns: v.boolean(),
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("unauthenticated");
return await cursors.beat(ctx, {
room: roomKey(identity.orgId ?? "global", args.path),
sessionId: args.sessionId,
userId: identity.subject,
name: typeof identity.name === "string" ? identity.name : undefined,
});
},
});
- Overlay in the UI, inside a
position: relative element:
import { CursorsOverlay } from "convex-multiplayer-cursors/react";
import { api } from "../convex/_generated/api";
<main style={{ position: "relative" }}>
<CursorsOverlay api={api.cursors} args={{ path: currentPath }} broadcast={canWrite} />
</main>
broadcast={false} watches without transmitting. getLabel, palette,
zIndex and knobs tune label, colors and cadences. To draw your own overlay
use the useCursors hook plus the exported pure math (timeline, sampler).
Click ripples
On by default (clicks={false} turns it off). A left click inside the
tracked area shows a local ring instantly and broadcasts a k: "click"
sample in the normal batch; remote overlays replay it in the clicker's color
when the playhead crosses the click. Zero extra queries, zero extra tables.
Clicks whose gesture was claimed by the app (preventDefault on
pointerdown) never ripple, and a per-batch cap demotes click floods to
plain movement on the server.
Cursor chat
Press /, type, others see the bubble glued to your cursor live. Enter
"sends" (the message lingers ~4s for others and the input clears for the
next one), Esc with text aborts the thought (bubble vanishes for everyone),
Esc on an empty input just closes. Sent text fades on its own; nobody has
to close anything.
Enable it by exposing two extra wrappers (same shape as the others) and the
overlay lights up on its own:
export const chats = query({ ...cursors.listChats(ctx, room) });
export const sendChat = mutation({ ...cursors.sendChat(ctx, { room, sessionId, userId, text }) });
A host without these wrappers keeps working exactly as before: the feature
simply stays off. chat={false} forces it off even with wrappers present.
Sharing / with your app was designed for, not against:
- The hotkey only opens when nothing else owns the key: no
input,
textarea, select, contenteditable or role="textbox" focused (the
check walks up the tree, so rich editors count), no IME composition in
flight, no Ctrl/Meta chords, no key repeat.
- The listener runs in the BUBBLE phase and respects
defaultPrevented: if
your app claims / first (command palette, search), chat yields.
- AltGr layouts (where
/ arrives as ctrl+alt on Windows) are recognized via
getModifierState("AltGraph").
chat={{ hotkey: "c" }} rebinds it; chat={{ hotkey: false }} disables
the key entirely while keeping remote bubbles visible.
- While the chat input is open, its keystrokes stop propagating, so
document-level app shortcuts do not fire mid-sentence.
Chat writes are throttled leading+trailing on the client (~150ms, the last
state always lands) with a server-side floor behind it, text is sanitized and
capped server-side (200 chars, control characters stripped, no surrogate
splitting), rendered via textContent (never HTML), and one bubble per
session is upserted, deleted on leave/expire and pruned if abandoned.
chat={{ maxLength, lingerMs, placeholder }} tunes the rest.
Server-side guard rails
Client cadence is an honor system; the contract lives in the component:
- Room capacity enforced on WRITE (joining a full room returns
false, so
roster and tracks never diverge by truncation);
- Flushes from the same session under 66ms apart are dropped;
- Every session belongs to the userId that created it (foreign beat returns
false, foreign flush/clear/leave are no-ops);
- Samples sanitized (finite, ascending t, max 32 per batch, unknown event
kinds demoted to plain movement, max 8 click events per batch) and
name
capped at 120 chars;
- Chat bubbles owned by their session (foreign writes return
false), text
clamped server-side, rewrites under 50ms dropped (deleting never is), stale
bubbles filtered from reads and pruned by the owner's own heartbeat.
On the client: a refused beat re-keys the sessionId automatically (covers full
rooms and expired ids taken by someone else), the heartbeat skips hidden tabs
(the session expires ONCE and is reborn on focus; throttled timers cannot make
it flap) and flush never fires without a confirmed session.
Example
example/ is a runnable playground (Vite + React + two scripted bots: the
GIF above). Point it at any Convex deployment:
cd example && npm install
echo "VITE_CONVEX_URL=https://<your-deployment>.convex.cloud" > .env.local
npx convex dev --once
npm run dev
node scripts/bot.mjs Ana bot-ana 1
Tests
109 tests: npm test (the component as a standalone mini-backend via
convex-test, including the
scheduler under fake timers) and npm run test:pure (timeline, sampler,
ripple event replay, hotkey compatibility, chat throttle and bubble
presentation in node:test; Node 24). Consumers register the component in
their own tests:
import cursorsTest from "convex-multiplayer-cursors/test";
cursorsTest.register(t, "cursors");
Defaults
TTL 45s, heartbeat 20s, flush 200ms, lag 260ms, 32 samples per batch, 128
sessions per room. Chat: hotkey /, throttle 150ms, linger 4s, 200 chars,
server floor 50ms, abandoned-bubble TTL 30s. Coordinates are pixels in the
host element's frame.
Upgrading from 0.1.x: additive schema and API (a bubbles table, an optional
k on samples). Deploy the new component before or together with the new
client bundle, which npx convex deploy already does in one go.
Upgrading from 0.2.0: additive schema (an optional chained flag on beats).
Expire jobs queued by 0.2.0 still run with their old check, and each live
session starts its single expire chain on its next heartbeat.
Scale: every flush re-runs the room's positions query and each subscriber
receives every cursor's latest batch, so traffic grows with (people moving) ×
(people watching). That is comfortable up to a few dozen active cursors per
room; for bigger rooms, shard rooms (per section, per document) rather than
raising ROOM_LIMIT.
Cost honesty: if your wrapper reads identity in its guard (it should), each
user re-executes their own copy of the queries when the room wakes; what the
architecture guarantees is the minimum number of WAKE-UPS (heartbeat zero,
movement only on the hot query), not a single shared execution.
Credits
Born in Amage's monorepo (first consumer: an internal module in production).
The invalidation design is inspired by
@convex-dev/presence and the sample
batching + single-flighting by Convex's official
multiplayer-cursors
example.
MIT.