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
| Demo | Live | Framework | Concepts demonstrated |
|---|---|---|---|
| chat | chat.dendri.dev | Svelte | Topics, store, broadcast |
| cursors | cursors.dendri.dev | Svelte | Awareness, throttling, ephemeral state |
| presence | presence.dendri.dev | Svelte | Presence API, peer lifecycle |
| whiteboard | whiteboard.dendri.dev | Svelte | Topic streams, canvas, late-joiner sync |
| collaborative-edit | collaborative-edit.dendri.dev | Svelte | Yjs CRDT sync (guide) |
| collaborative-layers | collaborative-layers.dendri.dev | Svelte | Yjs, structured shared state |
| file-transfer | file-transfer.dendri.dev | Svelte | Direct data connections, binary chunks |
| video-chat | video-chat.dendri.dev | Svelte | Media calls, streams |
| game | game.dendri.dev | Svelte | RPC, host authority, host migration |
| react-basic | react-basic.dendri.dev | React | Store + React integration |
| vue-basic | vue-basic.dendri.dev | Vue | Store + 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");
- Normalize coordinates (0–1) so cursors land correctly on different screen sizes.
- Throttle at the source: 30–60 ms feels live without wasting bandwidth.
- The production cursors demo uses Yjs awareness instead of a raw topic, which gives automatic cleanup and last-write-wins semantics (see Collaborative editing → Awareness).
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);
});
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>
}
});
});
- Use a room (or the store) first to discover peer IDs, then open the direct connection to the chosen peer.
- Binary payloads are serialized with BinaryPack automatically; large messages are chunked on the wire.
- Full demo with progress UI and drag-and-drop: file-transfer.
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;
}
- Set
fetchTurnCredentials: true: video is exactly where symmetric NATs bite. - Toggle mute/camera by flipping
track.enabledon the local stream's tracks; no renegotiation needed. - Full demo: video-chat.
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 });
});
- Intents in, state out: clients never mutate shared state directly; that's what keeps peers consistent and cheating hard.
- Re-register RPC methods on
hostChanged: a migrated host is a different peer with a clean slate. - For guaranteed delivery of critical events (game over, score), use
room.broadcastWithAck(data)which resolves when every connected peer confirms receipt. - Full demo: game.
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.