Multiplayer Cursors
Shared cursor presence
git clone https://github.com/bijectionhq/multiplayer-cursors.git
cd multiplayer-cursors
git checkout 9d5f88da90a174276207115bf153eda2eb4cd346Bijection repository access is required. Request access.
Description
Shared cursor presence
Explore the package README for API concepts, configuration, and examples. Check the Bijection source for adaptation-specific setup.
Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.
Getting started
- Get the source at the revision above and read its package documentation.
- Check the required Bijection SDK version, dependencies, and external service credentials.
- Follow the package setup instructions and the guide to using components.
- Exercise the component in your deployment according to your project’s verification rules.
Open source
Maintained source: bijectionhq/multiplayer-cursors. Based on Iann29/convex-multiplayer-cursors. License: MIT. Consult the repository for license terms, notices, and contributor attribution.
From the upstream publisher
Figma-style real-time multiplayer cursors for Convex: a reusable component (room presence with heartbeat/TTL, batched cursor trails) plus a React overlay with interpolated replay.
Explore the demo ↗Upstream overview
From the upstream package documentation.
convex-multiplayer-cursors is a Convex component that provides Figma-style real-time cursor presence across rooms. It splits state into three tables (sessions, beats, positions) with distinct invalidation profiles so heartbeats trigger zero query re-runs and cursor movement invalidates only a single hot query. The React overlay drives DOM updates through a requestAnimationFrame loop with interpolated playback, producing zero React re-renders per cursor movement.
Use cases
From the upstream package documentation.
- Adding live cursor presence to a collaborative document or whiteboard editor where multiple users work in the same room simultaneously
- Building a design tool or canvas where you need smooth, interpolated remote cursor trails rather than teleporting positions
- Implementing read-only cursor observation (broadcast={false}) so viewers can see collaborator positions without transmitting their own
- Scoping cursor rooms to organizational or document boundaries server-side, keeping presence isolated per tenant or per artifact
- Integrating multiplayer presence into an existing Convex app without coupling cursor logic to your main schema or query invalidation graph
How it works
From the upstream package documentation.
The component mounts via app.use(cursors) in convex/convex.config.ts and exposes isolated tables for sessions, beats, and positions. You write thin authenticated wrappers in your own convex/cursors.ts that call cursors.listSessions, cursors.beat, cursors.flush, and related methods, passing userId resolved server-side from ctx.auth. The roomKey helper scopes rooms to an org or document boundary. This keeps the component auth-agnostic while enforcing ownership invariants on every write.
On the server, room capacity is enforced at write time, flush rate is throttled to one batch per 66ms per session, and session ownership is validated so foreign beats and flushes are rejected. Cleanup runs as a scheduled expiry per heartbeat plus opportunistic pruning by live sessions, avoiding a single background sweep that would wake all queries.
The CursorsOverlay React component accepts your generated api.cursors object and a path arg, then subscribes to positions outside the React render cycle using watchQuery. Cursor positions are written to the DOM in a rAF loop with lagMs (default 260ms) of deliberate playback delay, ensuring there is always a next sample to interpolate toward. For custom rendering, the useCursors hook and exported timeline and sampler utilities are available as primitives.
Defaults are TTL 45s, heartbeat every 20s, flush every 200ms, 32 samples per batch, and up to 128 sessions per room. The package ships plain TypeScript with no build step, consuming directly via Convex's esbuild or Vite. Next.js users add the package to transpilePackages. A cursorsTest export lets consumers register the component in their own convex-test suites.
Upstream package FAQ
Does convex-multiplayer-cursors handle authentication itself?
No. convex-multiplayer-cursors never reads ctx.auth directly. You write thin wrapper mutations and queries in your own convex/ directory that authenticate via ctx.auth, determine the room key server-side, and pass userId into the component's Cursors class methods. This follows the standard Convex component pattern where the component receives only validated, app-supplied values.
How many sessions per room does the component support, and what happens when the limit is reached?
The default room capacity is 128 sessions. When a room is full, the beat mutation returns false instead of creating a new session. The client-side hook detects a refused beat and automatically re-keys the sessionId, so a returning user whose session expired and was reclaimed can still rejoin without manual intervention.
What is the performance cost of cursor movement in terms of Convex function calls?
convex-multiplayer-cursors uses single-flight batching: at most one mutation is in flight at a time, and position samples are flushed at most once every 200ms and only while the pointer is actively moving. On the subscriber side, movement invalidates only the positions query, not the session roster. Cursor positions are applied to the DOM in a requestAnimationFrame loop without triggering React re-renders.
Can I use convex-multiplayer-cursors without the built-in React overlay?
Yes. The package exports a useCursors hook plus pure utility functions for timeline interpolation and sample batching from convex-multiplayer-cursors/react. You can use these primitives to build a fully custom rendering layer while still relying on the component's backend for room management and data transport.
Does this component work with Next.js?
Yes, convex-multiplayer-cursors ships plain TypeScript source without a separate build step. On Next.js you need to add convex-multiplayer-cursors to the transpilePackages array in your Next.js config so the framework processes it through its own build pipeline. Vite and standard tsc with moduleResolution set to bundler work without any extra configuration.
Use with agents and CLI
Give your coding agent the component’s source identity and setup guidance.
Help me integrate Multiplayer Cursors with Bijection.
Read: https://bijection.com/components/multiplayer-cursors/SKILL.md
Check SDK compatibility and dependency availability.
Follow this project's execution and verification rules.Upstream README.md
View repository ↗Documentation from Iann29/convex-multiplayer-cursors, recorded October 23, 2026. APIs and setup may differ in the Bijection adaptation.
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.
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:
| Table | Written on | Subscribed by |
|---|---|---|
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):
// convex/cursors.ts
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");
// Room is born on the SERVER: your scope (org, doc, board) + sanitized path.
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,
});
},
});
// positions (query) and flush/clear/leave (mutations): same shape, always
// passing userId from the server. See CursorsApi in ./react for the contract.
- Overlay in the UI, inside a
position: relativeelement:
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({ /* auth */ ...cursors.listChats(ctx, room) });
export const sendChat = mutation({ /* auth */ ...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,contenteditableorrole="textbox"focused (the check walks up the tree, so rich editors count), no IME composition in flight, noCtrl/Metachords, 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 viagetModifierState("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
namecapped 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 # push the demo backend
npm run dev # open two tabs, move your mouse
node scripts/bot.mjs Ana bot-ana 1 # optional scripted participants
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.