Skip to content

Latest commit

Β 

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

weave

ci pages

Real-time multiplayer canvas with a hand-rolled CRDT. Open the same room in two tabs and watch shapes, freehand ink, images and live cursors merge conflict-free, with no server-side lock. Go offline, keep drawing, reconnect β€” the two diverged boards snap back to the same pixels. And because every edit is an immutable op, you can scrub the board's whole history like a video.

messaging is delivery. weave is convergence. The hard part isn't the WebSocket β€” it's the replicated data type underneath that makes concurrent edits provably agree, and the event-sourced op-log that turns "every change ever" into time-travel.

β–Ά Try it right now β€” jinwovo.github.io/weave

No install, no server, nothing to sign up for. The convergence playground puts two real replicas of this CRDT in one page, joined only by a simulated hostile network. Draw on both sides, crank up latency, drop and duplicate packets, cut the wire β€” the state fingerprints diverge in front of you and snap back to ≑ the moment delivery resumes. Hit β–Ά run the story for a 20-second guided tour:

The playground story: both replicas draw, the network turns hostile, a partition forces divergence, healing reconverges them

The full multiplayer app (below) is the same CRDT with a WebSocket and an op-log behind it:

Two clients in the same room β€” shapes and a live cursor converging in real time


Why it's different

Every distinctive feature falls out of the architecture, not bolted-on UI:

Feature Powered by
🟒 Multiplayer, conflict-free β€” concurrent edits never clobber the CRDT (LWW-Map over HLC)
✍️ Collaborative text β€” two people type in one note, character by character a sequence CRDT (Fugue, TPDS 2025), proven by a fault-injection sim
β†Ά Undo / redo β€” Ctrl+Z works with live collaboration, not against it inverse ops re-authored with a fresh stamp (LWW always wins)
πŸ‘€ Live presence β€” see peers' cursors and the shape they're drawing before they commit ephemeral draft preview fanned out on the cursor channel
πŸ”Œ Offline-resilient β€” keep editing while disconnected, reconnect β†’ auto-merge optimistic local CRDT + op outbox + reconcile
πŸ§ͺ Convergence playground β€” break the network yourself, watch replicas reconverge two in-page replicas over a simulated adversarial network
🧡 Interleaving lab β€” the same partitioned keystrokes through RGA and Fugue, live RGA shuffles the authors together; Fugue keeps each block intact
πŸ• Time-travel β€” scrub / replay the board's entire history the append-only op-log (event sourcing)
πŸ“š Hourly board + archive β€” fresh canvas each hour, past hours browsable read-only op-log bucketed by room base@<hour>
πŸ–Ό Images (paste / drop), ✏️ ink, β–­ shapes, ‑ resize, 🧽 erase all just shapes in the same convergent document

Problem

Multiplayer editing (Figma / Excalidraw / tldraw) needs shared state that:

  1. lets many people edit the same object simultaneously without a central lock,
  2. survives offline edits and reconnects without losing work, and
  3. converges β€” every replica that has seen the same edits shows the identical board.

A naΓ―ve "broadcast every change" approach silently diverges the moment two people touch the same thing at once. The correct tool is a CRDT (Conflict-free Replicated Data Type), and the correctness has to be proven, not eyeballed.

Design

A board is a map shapeId β†’ ShapeState. Every property of a shape is an independent Last-Writer-Wins register stamped with a Hybrid Logical Clock timestamp + actor id (globally unique, totally ordered). merge is the field-wise β†’ key-wise least upper bound, which is commutative, associative and idempotent β€” a join-semilattice, the sufficient condition for convergence. Operations are applied by merging the delta they imply, so they are immune to reordering and duplication. The server is a dumb relay + persistence layer, never the source of truth.

The same op-log is also an event store: replaying any prefix of it reconstructs the board at that moment β€” which is exactly what powers time-travel. And a room is bucketed as base@<hoursSinceEpoch>, so the live board resets every hour while each past hour stays preserved under its own room id and remains browsable.

See docs/adr/0001-lww-map-crdt-over-hlc.md for the decision and the alternatives (OT, RGA/YATA, OR-Set, off-the-shelf Yjs) that were rejected.

LWW is the right call for geometry and style, but text has to merge character-by-character β€” so text content is a sequence CRDT of its own. It started as RGA (ADR-0002), which converges but can interleave two users' concurrent runs of text β€” a limitation that ADR documented on day one. It is now Fugue (Weidner & Kleppmann, β€œThe Art of the Fugue”, IEEE TPDS 2025 Β· ADR-0005): each character is a node in a tree β€” hung to the right of its predecessor, or to the left of the element it displaces β€” so a run of typing forms a chain that concurrent runs cannot thread through. Deletes are tombstones, ops buffer until their dependency arrives (delivery is at-least-once and unordered), and every character carries a unique HLC id. It backs sticky / text shape bodies live β€” double-click a note and two people can type into it at once β€” and the interleaving lab lets you watch RGA fail where Fugue holds.

Two browsers typing into the same sticky β€” every character survives and the editors converge to "ABABABAB"

Undo/redo falls out of the same model. There is no "previous document" to rewind to β€” the log is append-only and shared β€” so each local edit records an inverse op (move β†’ move-back, create β†’ delete, delete β†’ re-create) that undo re-authors with a fresh timestamp. Because the board is last-writer-wins, a fresh stamp always wins, so "undo" is just "author the reverse edit now" β€” which commutes with everyone else's concurrent edits: undo my move after you recolor the same shape and your color stays. Per-user stack, zero server involvement. See docs/adr/0003-inverse-op-undo.md.

Three shapes drawn, then Ctrl+Z erases the board and redo brings every shape back to the exact pixel

Live presence is the same ephemeral channel as cursors. While you drag out a shape or ink a stroke, the in-progress draft is broadcast (throttled, never persisted) so collaborators watch it form β€” a translucent fill with a dashed outline in your colour β€” and disappears the instant you commit or cancel. So you don't just see where someone is, you see what they're about to draw.

Left: one user drags out a shape and sketches; right: the other watches the draft preview and cursor live

Architecture

flowchart LR
    subgraph CA["client A Β· browser"]
        DA["CanvasDoc + HlcClock<br/>optimistic Β· offline outbox"]
    end
    subgraph CB["client B Β· browser"]
        DB["CanvasDoc + HlcClock<br/>optimistic Β· offline outbox"]
    end

    subgraph APP["weave app Β· Spring Boot 4.1"]
        WS["WebSocket relay"]
        HTTP["HTTP API<br/>/history Β· /epochs"]
    end

    R[("Redis Pub/Sub<br/>ops Β· cursors fan-out")]
    PG[("PostgreSQL<br/>append-only op-log")]

    DA <-->|ops| WS
    DB <-->|ops| WS
    WS -->|persist| PG
    WS -->|publish| R
    R -->|fan-out| WS
    HTTP -->|replay| PG
    DA -. "time-travel Β· reconnect" .-> HTTP
    DB -. "time-travel Β· reconnect" .-> HTTP
Loading

A client applies each op to its local CanvasDoc immediately (optimistic), then sends it over the WebSocket. The relay persists it to the append-only op-log and publishes it to Redis; the Redis echo is the single broadcast path, so every instance β€” the origin included β€” relays an op to its own sockets. A late joiner gets a snapshot; time-travel and the hourly archive read that same op-log over HTTP. The server stores and fans out β€” it never decides the state; the CRDT does.

Cold-start recovery is bounded, not lifetime-long. A background sweeper materialises each busy room's fold into a canvas_snapshot row (every register with its HLC timestamp, tombstones included β€” lossless at the CRDT level), watermarked by an insertion-order seq. Rebuilding a room then folds snapshot + tail instead of the whole log β€” O(tail), tail ≀ threshold + one sweep window. It's exact, not approximate: the document fold is a join-semilattice, so folding any subset then the rest β€” any order, any overlap β€” rebuilds the identical document (property-tested). The watermark only advances past a grace window so a seq handed out before its transaction commits can never be skipped, and /history still reads the full log β€” snapshots bound recovery, they don't rewrite history. (ADR-0004)

Offline β†’ reconnect β†’ reconverge

The op-log + CRDT make a dropped connection a non-event: keep editing, reconnect, and the diverged boards merge by themselves.

sequenceDiagram
    participant A as client A
    participant S as weave app
    participant L as op-log (Postgres)
    participant B as client B

    Note over A: πŸ”Œ offline
    A->>A: edit locally (optimistic) + queue in outbox
    B->>S: edit (online)
    S->>L: append op

    Note over A: 🌐 reconnect
    A->>S: flush outbox
    S->>L: append (idempotent Β· room, actor, hlc)
    A->>S: GET /history
    S-->>A: full op-log
    A->>A: merge β€” LWW by HLC
    Note over A,B: converged β€” identical board
Loading

Verification

The convergence guarantee is enforced by property-based tests (jqwik), not anecdotes:

  • order- & duplication-independence β€” any delivery order of an op-log yields the same doc;
  • partition β†’ gossip β†’ convergence β€” replicas that each saw only a slice of the edits all reconcile to the identical board after exchanging state;
  • semilattice laws β€” merge is commutative, associative and idempotent;
  • HLC algorithm β€” monotonic ticks, counter reset on physical progress, receive-side advance;
  • sequence CRDTs (Fugue + RGA) β€” concurrently-authored text ops, delivered in every order with duplicates, converge to one string (character-level, not LWW);
  • non-interleaving β€” the property RGA cannot offer: concurrent runs typed at the same position (forward and prepending) stay contiguous under Fugue, across 300 randomized adversarial schedules each β€” plus the paper's canonical YATA-anomaly vector ("axb" is forbidden; RGA produces exactly it) and a differential test that runs one editing session through both CRDTs;
  • deterministic fault-injection simulation β€” a Jepsen-lite / DST harness: 3–4 replicas under an adversarial network (delay, reorder, drop-and-redeliver, duplicate) converge across 400 seeds, every run reproducible from its seed;
  • the snapshot contract β€” folding any prefix into a materialised snapshot, then the tail β€” shuffled, with part of the prefix re-delivered on top β€” rebuilds the exact full document.
./gradlew :crdt-core:test     # 27 tests

The sync server is proven end-to-end against real Postgres + Redis (Testcontainers): ops authored by one WebSocket client fan out to another, land in the durable op-log exactly once (a duplicate is absorbed by the (room, actor, hlc) constraint), and a late-joining client converges to the identical board from the snapshot alone β€” purely by replaying the op-log through the CRDT. SnapshotIntegrationTest additionally proves the sweeper snapshots a busy room, a cold start folds only the tail beyond the watermark, and the bounded rebuild β€” including a tombstone, a post-snapshot resurrection, and an idempotent double-refresh β€” equals the full-log fold exactly.

./gradlew :app:test           # 12 tests (Testcontainers PG + Redis)

Multi-instance convergence is proven by MultiInstanceConvergenceTest, which boots two app contexts sharing one Postgres + Redis and asserts an op authored on instance 1 reaches a client on instance 2 β€” only possible via the Redis fan-out. A k6 load test (load/convergence.js) across a 2-instance cluster measured op fan-out latency at median 16 ms Β· p90 70 ms for ~100 ops/s (77k messages fanned out, p99 β‰ˆ 550 ms tail on a cold local run).

k6 run load/convergence.js    # against two instances on :8103 and :8104

The convergence playground

The property tests prove convergence under an adversarial network; the playground is that exact fault model made interactive, so you can watch the proof instead of taking the README's word for it:

  • Two real replicas β€” each pane owns its own CanvasDoc, HLC clock and per-shape Fugue text, running the same TypeScript CRDT as the live client, and they exchange the same wire format the WebSocket protocol uses. The only thing simulated is the network.
  • An honest fault model β€” sliders for latency (Β±45% jitter, so packets genuinely reorder), loss and duplication, plus a one-click partition. Ops are reliable: a lost packet visibly dies mid-wire (βœ•) and is retransmitted until it lands β€” at-least-once, exactly like the real client's outbox against the server's idempotent op-log β€” and duplicates are absorbed because merge is idempotent. Cursors and draft previews are ephemeral: lost is lost, like the real cursor channel.
  • Falsifiable convergence β€” each pane shows a live fingerprint of its full CRDT state (every register with its HLC stamp, tombstones included, every text node). The badge compares them: β‰  while partitioned, ≑ once every op has been delivered. If the fingerprints ever differed with nothing in flight, the badge has a dedicated "this should be impossible" state β€” it has never rendered.

Mid-partition: both replicas edited, fingerprints differ, ops parked in outboxes

The README GIF above is recorded by scripts/capture-playground.mjs, which drives the β–Ά run the story tour against the exact static build GitHub Pages serves and fails unless the badge passes through the partition act and returns to CONVERGED with equal fingerprints β€” so the demo can't silently rot.

The interleaving lab

Below the playground sits a second experiment: the same partitioned editing session β€” two users each prepending items to one shared todo list β€” run through both of weave's sequence CRDTs at once. Every keystroke gets the identical HLC stamp in both engines; only the placement rule differs. RGA (timestamp-ordered, weave's original text type) converges to a fully shuffled B/A/B/A merge; Fugue keeps each author's items contiguous. Convergence was never the question β€” what the merged text reads like is.

The interleaving lab: identical keystrokes, RGA shuffles the two authors' lists together, Fugue keeps each author's block intact, both converge

scripts/capture-lab.mjs records this GIF and fails unless RGA interleaves (β‰₯3 author blocks), Fugue holds at exactly 2, and both engines' replica pairs end converged β€” the anomaly and the fix are both asserted, not narrated.

Stack

  • crdt-core β€” pure Java 21, zero production dependencies; tests on jqwik + JUnit 5.
  • app β€” Spring Boot 4.1: WebSocket relay, idempotent op-log on PostgreSQL (Flyway), Redis pub/sub fan-out, snapshot replay, and HTTP …/history + …/epochs endpoints (time-travel & archive).
  • web β€” Next.js 15 + Canvas client: shapes, freehand pen, sticky text, images (paste/drop), resize, eraser, undo/redo (Ctrl+Z), live cursors, presence. A faithful TypeScript port of the CRDT gives local-first optimistic edits that converge with the server and every client β€” including offline edits that flush + reconcile on reconnect. Plus time-travel replay, the hourly archive, and the playground (/playground) β€” the same client CRDT over an in-page simulated network, statically exported to GitHub Pages (no backend at all).
  • observability β€” Micrometer metrics on /actuator/prometheus, scraped by Prometheus into a provisioned Grafana dashboard: active sessions/rooms, op ingest rate + persist latency (p50/p95/p99 via histogram buckets), HTTP p95 per route, JVM heap, and the snapshot row β€” snapshots written, cold-start tail size, refresh/replay latency.

Grafana dashboard under k6 load β€” 24 live sessions, op ingest rate and persist latency

Local ports (per workspace registry): app 8103, PostgreSQL 5437, Redis 6383, front 3009, Prometheus 9099, Grafana 3011. Container prefix weave-.

Roadmap

Phase Scope Status
P0 Pure-Java CRDT core (LWW-Map over HLC) + property-based convergence proof βœ… done
P1 Spring Boot 4.1 sync server: WebSocket relay, idempotent op-log, Redis fan-out, snapshot replay βœ… done
P2 Next.js + Canvas client: shapes, pen, sticky text, images, resize, eraser, live cursors, presence βœ… done
P3 Distinctive layer: time-travel replay Β· offline β†’ reconnect reconvergence Β· hourly board + archive βœ… done
P4 Multi-instance convergence (two app instances share Postgres + Redis) + k6 fan-out-latency load test βœ… done
P5 Playwright two-client demo GIF + product polish (PNG export, copy-link) βœ… done
P6 Sequence CRDT (RGA) collaborative text + deterministic fault-injection sim Β· Prometheus + Grafana observability βœ… done
P7 Undo / redo via inverse ops β€” per-user, concurrency-safe, zero server change βœ… done
P8 Snapshot-accelerated replay β€” lossless CRDT snapshots + seq watermark + grace window; cold start folds O(tail), proven exact by property test βœ… done
P9 Convergence playground β€” two in-page replicas over a simulated adversarial network (latency/loss/dup/partition), state fingerprints, scripted tour; deployed server-less to GitHub Pages βœ… done
P10 Fugue text CRDT (Weidner & Kleppmann, IEEE TPDS 2025) replaces RGA β€” non-interleaving concurrent text, proven by new property tests + 400-seed DST Β· interleaving lab in the playground runs RGA vs Fugue on identical keystrokes βœ… done

Quickstart

Zero-install: the playground runs the CRDT entirely in your browser. Everything below is for the full multiplayer stack.

On Windows, one command brings up the whole stack (Docker Desktop β†’ containers β†’ app β†’ web) and opens the board β€” it skips whatever is already running:

powershell -ExecutionPolicy Bypass -File scripts/dev-up.ps1

Or piece by piece:

git clone https://github.com/jinwovo/weave && cd weave

./gradlew :crdt-core:test     # P0 convergence proofs (no Docker needed)

docker compose up -d          # weave-postgres (5437) + weave-redis (6383)
./gradlew :app:test           # sync-server proof (Testcontainers spins its own PG + Redis)
./gradlew :app:bootRun        # run the sync server on http://localhost:8103  (WS: /ws?room=&actor=)

cd web && npm install && npm run dev    # canvas client on http://localhost:3009
# open two tabs at http://localhost:3009/?room=demo β€” draw, paste an image, go offline, hit πŸ• / πŸ“š

The client tells you when the sync server isn't reachable (drawing still works locally β€” live cursors, history and the archive are what need it).

About

A multiplayer whiteboard on a hand-rolled CRDT - offline merge, time travel, character-level co-editing. Try the in-browser playground: partition the network, watch two replicas reconverge.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages