Skip to content

Find a component

Loading the directory…

↑ ↓ to select · Enter to open · Esc to closeBrowse all →
Contributor login

Multiplayer Cursors

Shared cursor presence

Bijection source
git clone https://github.com/bijectionhq/multiplayer-cursors.git
cd multiplayer-cursors
git checkout 9d5f88da90a174276207115bf153eda2eb4cd346

Bijection 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.

Available to evaluate

Review the source and its dependencies before adopting this package. Source availability does not establish npm publication or runtime qualification.

Getting started

  1. Get the source at the revision above and read its package documentation.
  2. Check the required Bijection SDK version, dependencies, and external service credentials.
  3. Follow the package setup instructions and the guide to using components.
  4. 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.

agent-prompt.md
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.

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:

TableWritten onSubscribed by
sessionsjoin/leave/name changeroster (cold)
beatsheartbeat (~20s)NOBODY
positionsbatch flush (~5/s while moving)room positions (hot)
bubbleswhile 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)

  1. 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;
  1. 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.
  1. 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({ /* 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, 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   # 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.

Keywords