Guides

Building the examples

Twelve open-source demos ship in dendri-examples. This page walks through the core pattern behind each category so you can rebuild them (or build your own variant) in any framework.

The demo catalogue

DemoLiveFrameworkConcepts demonstrated
chatchat.dendri.devSvelteTopics, store, broadcast
cursorscursors.dendri.devSvelteAwareness, throttling, ephemeral state
presencepresence.dendri.devSveltePresence API, peer lifecycle
whiteboardwhiteboard.dendri.devSvelteTopic streams, canvas, late-joiner sync
collaborative-editcollaborative-edit.dendri.devSvelteYjs CRDT sync (guide)
collaborative-layerscollaborative-layers.dendri.devSvelteYjs, structured shared state
file-transferfile-transfer.dendri.devSvelteDirect data connections, binary chunks
video-chatvideo-chat.dendri.devSvelteMedia calls, streams
gamegame.dendri.devSvelteRPC, host authority, host migration
react-basicreact-basic.dendri.devReactStore + React integration
vue-basicvue-basic.dendri.devVueStore + Vue integration

Every demo is a standalone Vite project: cd in, pnpm install, pnpm dev, and set VITE_DENDRI_URL (or PUBLIC_DENDRI_URL for the SvelteKit demos) to your server.

Chat

Live demo: chat.dendri.dev. Open it in two tabs to see the sync.

Concepts: topics, store. Covered end-to-end in the Quickstart. The one-line summary: publish on a topic, subscribe to the topic, render. Everything else on this page is a variation of that loop.

Live cursors

Live demo: cursors.dendri.dev. Open it in two tabs to see the sync.

Concepts: ephemeral state, throttling. Cursor positions are high-frequency and disposable: never persist them, and throttle broadcasts so you don't flood slower peers. The simplest version uses a topic:

import { createDendriStore } from "@afterrealism/dendri-client";
import { DENDRI_OPTIONS } from "./dendri-options";

const store = createDendriStore(DENDRI_OPTIONS);
const cursors = new Map(); // peerId → { x, y, name, color }

const THROTTLE_MS = 50; // 20 updates/sec is plenty for cursors
let lastSent = 0;

window.addEventListener("pointermove", (event) => {
	const now = Date.now();
	if (now - lastSent < THROTTLE_MS) return;
	lastSent = now;

	store.broadcast(
		{ x: event.clientX / innerWidth, y: event.clientY / innerHeight },
		{ topic: "cursor" },
	);
});

store.subscribe("cursor", (data, peerId) => {
	cursors.set(peerId, data);
	render();
});

// Remove cursors of peers that leave.
store.onPeerLeave((peerId) => {
	cursors.delete(peerId);
	render();
});

function render() {
	for (const [peerId, c] of cursors) {
		const el = cursorElement(peerId); // create-or-get a positioned <div>
		el.style.transform = `translate(${c.x * innerWidth}px, ${c.y * innerHeight}px)`;
	}
}

store.join("cursor-room");

Presence

Live demo: presence.dendri.dev. Open it in two tabs to see the sync.

Concepts: presence API, peer lifecycle. Presence is structured state about each peer (name, avatar, status, current document) synced by the room automatically. Unlike topics, the latest presence of every peer is always available in the snapshot, even for peers who published before you joined.

const store = createDendriStore(DENDRI_OPTIONS);

// Publish my presence. Call again any time it changes; peers see updates live.
store.join("team-room");
store.setPresence({ name: "Ana", status: "editing", section: "intro" });

// React to everyone's presence in one place.
store.subscribe(() => {
	const { presences, peers } = store.getSnapshot();
	const people = peers.map((peerId) => ({
		peerId,
		...(presences.get(peerId) ?? { name: "Anonymous", status: "idle" }),
	}));
	renderAvatarRow(people);
});

// Or listen for individual updates:
store.onPresenceUpdate((peerId, data) => {
	console.log(`${peerId} is now`, data);
});
Presence vs topics: use presence for "current state of a person" (replaces previous value, survives late joins) and topics for "events" (fire and forget, only live subscribers see them).

Whiteboard

Live demo: whiteboard.dendri.dev. Open it in two tabs to see the sync.

Concepts: streaming a topic, late-joiner sync via RPC. A shared canvas broadcasts stroke segments as they're drawn. The subtlety is history: a peer who joins late missed the strokes, so ask the host to replay them once via RPC.

const store = createDendriStore(DENDRI_OPTIONS);
const canvas = document.querySelector("canvas");
const ctx = canvas.getContext("2d");
const history = []; // every segment we've seen, for replays

// --- drawing locally → broadcast segments -------------------------------
let drawing = false;
let prev = null;

canvas.addEventListener("pointerdown", (e) => { drawing = true; prev = point(e); });
canvas.addEventListener("pointerup", () => { drawing = false; prev = null; });
canvas.addEventListener("pointermove", (e) => {
	if (!drawing) return;
	const next = point(e);
	const segment = { from: prev, to: next, color: myColor, width: 3 };
	drawSegment(segment);
	history.push(segment);
	store.broadcast(segment, { topic: "draw" });
	prev = next;
});

// --- receiving remote segments ------------------------------------------
store.subscribe("draw", (segment) => {
	drawSegment(segment);
	history.push(segment);
});

// --- late-joiner catch-up -------------------------------------------------
// The host answers "history" RPCs with everything drawn so far.
store.onHostChanged(() => {
	// (Re-)register on whichever peer is currently host, including after migration.
});

function drawSegment({ from, to, color, width }) {
	ctx.strokeStyle = color;
	ctx.lineWidth = width;
	ctx.lineCap = "round";
	ctx.beginPath();
	ctx.moveTo(from.x * canvas.width, from.y * canvas.height);
	ctx.lineTo(to.x * canvas.width, to.y * canvas.height);
	ctx.stroke();
}

function point(e) {
	const r = canvas.getBoundingClientRect();
	return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height };
}

store.join("whiteboard-room");

For a whiteboard that needs guaranteed convergence (undo, erasing, reordering), skip manual history and model strokes in a Yjs document instead; that's exactly what collaborative-layers does. See Collaborative editing.

File transfer

Live demo: file-transfer.dendri.dev. Open it in two tabs to see the sync.

Concepts: direct 1:1 data connections, binary chunking, backpressure. Files go peer-to-peer over a dedicated reliable data connection, so the server never sees the bytes. Use the low-level Dendri API for this:

import { Dendri } from "@afterrealism/dendri-client";

const peer = new Dendri(DENDRI_OPTIONS);
const CHUNK_SIZE = 64 * 1024; // 64 KiB

async function sendFile(remotePeerId, file) {
	const conn = peer.connect(remotePeerId, { reliable: true });

	conn.on("open", async () => {
		// 1. Announce what's coming.
		conn.send({ kind: "meta", name: file.name, size: file.size, mime: file.type });

		// 2. Stream the chunks.
		for (let offset = 0; offset < file.size; offset += CHUNK_SIZE) {
			const chunk = await file.slice(offset, offset + CHUNK_SIZE).arrayBuffer();
			conn.send({ kind: "chunk", offset, chunk });
		}

		// 3. Signal completion.
		conn.send({ kind: "done" });
	});
}
peer.on("connection", (conn) => {
	let meta = null;
	const parts = [];

	conn.on("data", (msg) => {
		if (msg.kind === "meta") {
			meta = msg;
			parts.length = 0;
		} else if (msg.kind === "chunk") {
			parts.push(msg.chunk);
			updateProgress(parts.length * 64 * 1024, meta.size);
		} else if (msg.kind === "done") {
			const blob = new Blob(parts, { type: meta.mime });
			offerDownload(blob, meta.name); // URL.createObjectURL + <a download>
		}
	});
});

Video chat

Live demo: video-chat.dendri.dev. Open it in two tabs to see the sync.

Concepts: media connections, room discovery. Media calls use the same peer IDs as data: get a local MediaStream, call each peer in the room, and answer incoming calls. A deterministic "who calls whom" rule prevents double calls:

import { Dendri } from "@afterrealism/dendri-client";

const peer = new Dendri(DENDRI_OPTIONS);
const localStream = await navigator.mediaDevices.getUserMedia({
	video: true,
	audio: true,
});
addVideoTile("me", localStream, { muted: true });

peer.on("open", () => {
	peer.joinRoom("video-room");
});

// The server tells us who's in the room.
peer.on("roomPeers", (room, peers) => {
	for (const id of peers) {
		if (id === peer.id) continue;
		// Deterministic rule: the lexicographically smaller ID initiates.
		if (peer.id < id) {
			const call = peer.call(id, localStream);
			call.on("stream", (remote) => addVideoTile(id, remote));
			call.on("close", () => removeVideoTile(id));
		}
	}
});

// Answer calls from peers that joined after us.
peer.on("call", (call) => {
	call.answer(localStream);
	call.on("stream", (remote) => addVideoTile(call.peer, remote));
	call.on("close", () => removeVideoTile(call.peer));
});

function addVideoTile(id, stream, { muted = false } = {}) {
	const video = document.createElement("video");
	video.autoplay = true;
	video.playsInline = true;
	video.muted = muted; // always mute your own tile
	video.srcObject = stream;
	document.querySelector("#grid").appendChild(video);
	video.dataset.peer = id;
}

Multiplayer game

Live demo: game.dendri.dev. Open it in two tabs to see the sync.

Concepts: RPC, host authority, host migration. Real-time games need one source of truth. In Dendri's star topology the host is the natural authority: clients send intents via RPC, the host validates them and broadcasts the new state.

import { Dendri, Room } from "@afterrealism/dendri-client";

const room = new Room("game-42", { migrationTimeout: 5000 });
let state = { players: {}, ball: { x: 0.5, y: 0.5 } };

room.join(Dendri, DENDRI_OPTIONS);

room.on("joined", (peerId, isHost) => {
	if (isHost) becomeAuthority();
});

// If the host disconnects, a deterministic election promotes a new one.
room.on("hostChanged", (newHostId) => {
	if (room.peerId === newHostId) becomeAuthority();
});

function becomeAuthority() {
	// Clients call "move"; only the host validates and applies it.
	room.registerRpcMethod("move", (payload, sender) => {
		const { dx, dy } = payload;
		if (Math.abs(dx) > 1 || Math.abs(dy) > 1) return { ok: false }; // anti-cheat
		state = applyMove(state, sender, dx, dy);
		room.broadcast(state, { topic: "state" });   // fan out the new truth
		return { ok: true };
	});
}

// --- client side ----------------------------------------------------------
room.subscribe("state", (newState) => {
	state = newState;
	render(state);
});

document.addEventListener("keydown", async (e) => {
	const dir = keyToDirection(e.key);
	if (!dir) return;
	// Send the intent to the host and await confirmation.
	await room.performRpc("move", dir, { peerId: room.hostId, timeout: 2000 });
});

Collaborative editing

Live demo: collaborative-edit.dendri.dev. Open it in two tabs to see the sync.

Text editors, kanban boards, and design canvases need CRDT-grade conflict resolution, and that's a separate guide: Collaborative editing with Yjs, which powers the collaborative-edit and collaborative-layers demos.