An offline-first collaborative planning board, built entirely in Go — real-time multiplayer kanban with a collaborative text editor inside every card. Both sync engines (Operational Transformation and CRDTs) are implemented from scratch, and the browser client is Go compiled to WebAssembly.
- A shared kanban board. Cards, columns, drag-to-move, votes, editable titles — every change fans out live to everyone looking at the board.
- A collaborative text document inside every card. Open a card's description in two browsers and type simultaneously; the edits merge Google-Docs style, with carets staying put as remote edits land.
- Offline-first, for real. Kill the network and keep working: board edits and every keystroke are persisted locally (IndexedDB) as they happen. Close the tab while offline, reopen later — nothing is lost. On reconnect, everything merges cleanly with what everyone else did meanwhile.
- Honest conflict surfacing. If two people move the same card to different columns while one was offline, both converge on one winner and the card shows a banner naming the kept move — conflict transparency instead of silent resolution.
- OT implemented from scratch (
internal/ot): retain/insert/delete operations withApply,Compose, andTransform. The TP1 property — the correctness law of server-based OT — is verified by a fuzzer, not by hope. Insert-position ties break deterministically by client ID, so concurrent inserts converge identically on every replica. - A sequence CRDT implemented from scratch (
internal/crdt): a textbook RGA (Replicated Growable Array) with Lamport-clock IDs, tombstoned deletes, and causal buffering. Convergence (any order, any duplication → same text) is fuzz-tested. - A hybrid sync model for text: OT through the central server while online; the local RGA while offline; on reconnect, the offline delta is diffed into a single composite OT operation and submitted through the ordinary pipeline. The server never learns the CRDT existed.
- An always-on CRDT layer for the board (
internal/boardcrdt): add-wins OR-Set/OR-Map for cards and their placement, LWW registers for fields, PN-Counters for votes, fractional indexing for ordering. No revisions, no transforms — offline board edits are just late-delivered ops. - Go on both sides of the wire: the client sync engines are the same Go packages the tests exercise natively, compiled to WebAssembly. The JS shell is deliberately dumb — DOM events in, rendered HTML out.
- Hub-pattern concurrency: one event-loop goroutine owns all document state; connections talk to it over channels. No mutexes anywhere near the hot path — the loop is the total order that OT requires.
- Zero CGO, two dependencies: pure-Go SQLite (modernc.org/sqlite) and a WebSocket library (coder/websocket). Everything else is the standard library.
Requires Go 1.26+.
./build.sh # builds the WASM client, then the server (which embeds it)
./bin/server # serves http://localhost:8080 (SQLite db: ./collab.db)
build.shlooks for the Go toolchain at~/.local/go/bin/goby default; point it elsewhere withGO=$(command -v go) ./build.sh.
Open http://localhost:8080 in two browser windows side by side, then try:
- Add cards, drag them between columns, vote — watch the other window follow.
- Click a card's ✎ and type in its description in both windows at once — the text converges without clobbering either side.
- DevTools → Network → Offline in one window. Keep typing and moving cards — everything keeps working and is saved locally. Go back online: offline edits merge with whatever the other window did meanwhile.
- While one window is offline, move the same card to different columns in each window, then reconnect: both settle on one column and show a conflict banner naming the kept move.
go test -race ./...Property and fuzz tests are the correctness backbone. The seed corpora run on
every go test; to actively fuzz:
go test -fuzz=FuzzTP1 -fuzztime=30s ./internal/ot # OT transform correctness
go test -fuzz=FuzzCompose -fuzztime=30s ./internal/ot # compose ≡ sequential apply
go test -fuzz=FuzzConvergence -fuzztime=30s ./internal/crdt # RGA convergence
go test -fuzz=FuzzBoardConvergence -fuzztime=30s ./internal/boardcrdt # board CRDT convergence
go test -fuzz=FuzzDiff -fuzztime=30s ./internal/client # apply(a, diff(a,b)) == bIf Node.js is on PATH, the cmd/wasm tests also run a live end-to-end
suite: two real WASM client builds under Node against a real server, typing
concurrently, dropping the network, and reconciling. Without Node those tests
skip.
The whole server contract is two knobs:
-addr/$PORT— the server bindslocalhost:8080by default. Set$PORTand it binds:$PORT(all interfaces — the usual platform convention); pass-addrexplicitly and that wins over both.-db— the SQLite file. Put it on a persistent volume; everything else is embedded in the binary.
The included multi-stage Dockerfile builds the WASM client and the server
exactly like build.sh and ships a single static binary on a distroless
base, with the database at /data/collab.db.
Run exactly one instance. The hub's event loop is both the OT total order and SQLite's single writer — two replicas means two divergent boards, and scaling to zero drops live WebSocket sessions. No horizontal scaling, no scale-to-zero.
fly launch --no-deploy # uses fly.toml; pick a name + region
fly volumes create collab_data --region <region> --size 1
fly deployfly.toml already pins the single-instance shape (min_machines_running = 1,
auto-stop off); keep fly scale count 1.
- Railway / Render — work as-is: both inject
$PORT; attach a persistent volume/disk and point-dbat it. - Any VPS —
docker build -t collab . && docker run -p 8080:8080 -v collab-data:/data collab, behind whatever TLS proxy you like (WebSockets pass through nginx/Caddy fine).
| Path | What lives there |
|---|---|
cmd/server |
Server entrypoint: wiring, graceful shutdown |
cmd/wasm |
Browser client entrypoint: DOM/WebSocket bridge, board rendering, UTF-16 boundary |
internal/ot |
Operational Transformation core: ops, Apply/Compose/Transform, TP1 fuzzing |
internal/crdt |
Sequence CRDT (RGA): IDs, tombstones, causal buffering, convergence fuzzing |
internal/boardcrdt |
Board CRDTs: OR-Set/OR-Map, LWW registers, PN-Counters, fractional indexing |
internal/client |
OT client state machine (Synchronized / AwaitingConfirm / AwaitingWithBuffer) + text diff |
internal/sync |
The hybrid engine: Online→Offline→Reconciling state machine and reconnect reconciliation |
internal/boardclient |
Client half of the board layer: replica, offline queue, reconnect flush |
internal/hub |
The server: event-loop hub, per-document rooms, multi-doc routing, board fan-out |
internal/protocol |
The JSON wire protocol — one codec shared by server and client |
internal/store |
Persistence: snapshot + append-only op log on SQLite (plus an in-memory test double) |
internal/idb |
Minimal IndexedDB wrapper for the WASM client |
web |
HTTP layer: embedded templates and static assets (JS shell, CSS) |
For the full design — the two sync layers, the reconnect reconciliation algorithm, the server's concurrency model, and the testing philosophy — see ARCHITECTURE.md.
- Single server instance. A document lives in one process; there is no clustering or horizontal scaling of a single board.
- No authentication. Anyone who can reach the server can edit the board.
- No presence or remote carets. You see other people's edits, not their cursors. (The caret-transform machinery exists and is tested; the presence protocol and rendering do not.)
- One offline session per browser. Multiple tabs editing the same document offline share one local session and can interfere; keep offline editing to a single tab.
- Plain text only in card descriptions — no rich text.
- The WASM binary is ~4 MB uncompressed; fine on localhost, worth compressing before serving it anywhere else.