New:Introducing Socket Scanning for VS Code Marketplace Extensions.Learn more →
Get Started

convex-multiplayer-cursors

Package Overview
Dependencies
Maintainers
1
Versions
5
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

convex-multiplayer-cursors

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.

latest
Source
npmnpm
Version
0.2.1
Version published
Weekly downloads
0
Maintainers
1
Weekly downloads
 
Created
Source

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.

demo

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)

  • 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: 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

convex

FAQs

Package last updated on 23 Sep 2026

Related posts