# Multiplayer Cursors

Shared cursor presence

- Package: @bijectionhq/multiplayer-cursors
- Category: Collaboration
- Status: Preview
- License: MIT
- Bijection source (repository access required): https://github.com/bijectionhq/multiplayer-cursors/tree/9d5f88da90a174276207115bf153eda2eb4cd346
- Upstream: https://github.com/Iann29/convex-multiplayer-cursors/tree/9b90a448c9a7c8d63b5bbe367dd4559d3e8e926e
- Documentation: https://docs.bijection.com/components/using
- Directory: https://bijection.com/components/multiplayer-cursors

## Get the source

Bijection repository access is required. Request access at https://bijection.com/contact.

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

## Before using this component

Read the package README at the recorded revision for setup, dependencies, and API examples. Upstream documentation describes the original package and may differ from the Bijection adaptation. This is a source candidate for evaluation. Catalog inclusion does not establish npm availability or runtime qualification. Check compatibility with your SDK and deployment before adoption. External providers run their own compute.

## Upstream package documentation

Recorded 2026-10-23. This describes convex-multiplayer-cursors; check the Bijection adaptation separately.

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

- 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

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 README

# convex-multiplayer-cursors

Real-time multiplayer cursors (Figma-style) for [Convex](https://convex.dev):
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](README.pt-BR.md)

## 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

```bash
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`:

```ts
import { defineApp } from "convex/server";
import cursors from "convex-multiplayer-cursors/convex.config";

const app = defineApp();
app.use(cursors);
export default app;
```

2. **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):

```ts
// 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.
```

3. **Overlay in the UI**, inside a `position: relative` element:

```tsx
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:

```ts
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:

```bash
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](https://github.com/get-convex/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:

```ts
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](https://github.com/get-convex/presence) and the sample
batching + single-flighting by Convex's official
[multiplayer-cursors](https://github.com/get-convex/multiplayer-cursors)
example.

MIT.