Skip to content
MattKulkaPublic

About

Real-time collaborative canvas — a Figma/Excalidraw-style multiplayer whiteboard on a hand-built Canvas 2D render loop + Yjs CRDT sync

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

11 Commits

Folders and files

Repository files navigation

Overlay — a real-time collaborative canvas

Overlay is a multiplayer whiteboard — a focused clone of the core Figma/Excalidraw experience. Open the same board in two tabs and you see each other's cursors move live, draw shapes, and edit in perfect sync. It's built on a hand-written HTML5 Canvas 2D render loop (no canvas/whiteboard library) and CRDT sync (Yjs), and it runs entirely on your machine with no accounts.

Overlay canvas with shapes, an arrow, a sticky note and freehand ink

Highlights

  • Infinite canvas with smooth pan (space-drag / middle-mouse / touch) and zoom-to-cursor (wheel / pinch), all on a custom requestAnimationFrame loop that only redraws on change.
  • Seven tools — select, rectangle, ellipse, line, arrow, freehand pen, text, sticky note — with a clean shape model and a contextual style panel.
  • Full manipulation — click/marquee/shift multi-select, move, 8-handle resize, rotate (single shapes and groups), snapping to other shapes' edges and centers with alignment guides, z-order, duplicate, delete.
  • History — undo/redo backed by a Yjs UndoManager scoped to your edits, so undo never reverts a collaborator's work.
  • Real-time collaboration — shapes sync through a shared Yjs document; live interpolated cursors with names and colors; an avatar stack; peer selection highlights in each peer's color; a shareable /b/:id URL.
  • Local-first — the document persists to IndexedDB, so a solo reload keeps your work even offline.
  • Export — the board or a selection to PNG (high-res, cropped) or SVG.
  • Polished — light/dark themes, responsive + touch, keyboard shortcuts, visible focus rings, prefers-reduced-motion, and a steady 60fps with 500+ shapes while a cursor moves.

Two people on one board: a live named cursor and a peer's selection in their color

Run it locally

Requires Node 18+ and pnpm.

pnpm install

# Terminal 1 — the local Yjs sync server (WebSocket, port 1234)
pnpm ws

# Terminal 2 — the app
pnpm dev

Open the printed URL, then open it again in a second tab (or paste the Share link) to collaborate. Other scripts:

pnpm build   # typecheck (tsc) + production build
pnpm lint    # ESLint + Prettier (must be clean; no `any`)
pnpm test    # Vitest unit tests (geometry/transform)
pnpm e2e     # Playwright, incl. a two-context multiplayer test

Architecture

Coordinate system

A single camera { x, y, zoom } where (x, y) is the world coordinate that maps to screen pixel (0, 0):

screen = (world − camera) · zoom
world  = screen / zoom + camera

All shape geometry is stored in world space; only the render step converts to screen. Zoom-to-cursor keeps the world point under the pointer fixed. Handles and hit tolerances are defined in screen pixels and divided by zoom, so they stay a constant size on screen at any zoom. See src/geometry/coords.ts.

The render loop & layered canvases

src/canvas/RenderLoop.ts is a requestAnimationFrame scheduler with a dirty flag: when nothing changes it stops scheduling frames entirely (true "redraw only on change") and re-arms on the next mutation. It also guarantees a synchronous first paint and falls back to setTimeout when the tab is hidden (background tabs throttle rAF).

BoardCanvas stacks two canvases:

  • a base layer (dot grid + shapes) that re-rasterizes only when the scene, camera or theme changes, and
  • an overlay layer (selection outlines, transform handles, marquee, alignment guides, and remote cursors) that animates every frame while peers are moving.

This split is what holds 60fps: 500 static shapes are never re-drawn just because a collaborator's cursor moved.

CRDT sync — one source of truth

Shapes live in a Yjs document (src/sync/scene.ts), not in component state — even in single-player. Each shape is a nested Y.Map, so two people editing different fields of the same shape (one moving it, one recoloring it) merge without conflict. A cached snapshot is rebuilt on observeDeep and exposed to React via useSyncExternalStore and to the render loop via a subscription.

Everything transport-specific sits behind a SyncProvider interface (src/sync/). YjsSync implements it with y-websocket (network) + y-indexeddb (persistence); it could be swapped for Liveblocks/PartyKit without touching the app. Local UI state (camera, active tool, selection, drag state, theme) lives in a small Zustand store, never in the CRDT.

Presence

Presence rides Yjs awareness: each client publishes { user{name,color}, cursor, selection }. Remote cursors are interpolated toward their target each frame for smooth motion, drawn with a name label in the peer's color; peer selections are outlined in that same color. The avatar stack re-renders only on membership/name changes — not on every cursor move.

Hard problems worth calling out

  • Rotation-aware resize. Dragging any of 8 handles resizes a possibly-rotated box while keeping the opposite anchor fixed in world space. The math works in the shape's local frame (src/geometry/transform.ts) and is unit-tested, including the rotated case and group scaling.
  • Local-scoped undo in a shared document. A naive UndoManager would let you undo a collaborator's edits. Scoping it to a local transaction origin (trackedOrigins) and breaking the capture group at each gesture boundary gives clean, per-user, per-action undo.
  • 60fps under load. Achieved by layering the canvases and keeping the base layer idle unless the scene or camera actually changes. Measured at ~60fps while continuously panning a 500-shape board.
  • Rendering in a throttled tab. The loop guarantees a first paint and degrades to setTimeout when document.hidden, so state changes still reach the canvas in backgrounded/embedded contexts.
  • Snapping with guides. Moving a selection snaps its edges/centers to nearby shapes within a screen-constant threshold, and draws full-length alignment guides — the geometry is pure and unit-tested.

Tech stack

React 18 · TypeScript (strict, no any) · Vite · custom Canvas 2D · Zustand · Yjs + y-websocket + y-indexeddb · Tailwind CSS · lucide-react · Vitest · Playwright.

Testing

  • Vitest covers the correctness-critical pure geometry — coordinate conversion & zoom-to-cursor, resize/rotate/group transforms, and snapping — written test-first.
  • Playwright drives real flows, including a two-context multiplayer test that proves edits and cursors sync between tabs, and a presence test asserting peers see each other's cursors, selections and avatars.

Non-goals

Accounts/auth, a backend database, comments, a component library, image uploads, and plugins are intentionally out of scope. Depth over breadth.

About

Real-time collaborative canvas — a Figma/Excalidraw-style multiplayer whiteboard on a hand-built Canvas 2D render loop + Yjs CRDT sync

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages