Collaborative Canvas
elements man recipes/collaborative-canvas Read as markdownA shared drawing canvas where every browser sees every other browser's strokes
appear live, plus a "clear" button that wipes the canvas for everyone. The
recipe stores nothing: there is no database table and no LiveTable. A single
Channel carries every stroke and every clear event. Strokes that arrive after
the page loads are drawn; strokes that arrived before the page loaded are gone.
This is the canonical Channel-only recipe. Nothing about it persists. When that fits the problem (cursors, presence pings, drawing strokes, ephemeral UI state), the channel is the lowest-overhead primitive available.
Page setup
elements create page canvas
There is no migration: the canvas owns no rows. The channel, the event type, and
the broadcast rpc are used only by the /canvas page, so they live in the
page's own services.ts rather than under app/shared/.
app/pages/canvas/services.ts:
import { Channel } from "@elements/app";
export interface Stroke {
color: string;
points: { x: number; y: number }[];
}
export type CanvasEvent =
| { kind: "stroke"; stroke: Stroke }
| { kind: "clear" };
export const events = new Channel<CanvasEvent>("canvas");
/** @rpc */
export function broadcastStroke(stroke: Stroke) {
events.notify({ kind: "stroke", stroke });
}
/** @rpc */
export function broadcastClear() {
events.notify({ kind: "clear" });
}
CanvasEvent is a discriminated union: a "stroke" event carries the points
and color, a "clear" event carries no data. The rpc wrappers exist because
Channel.notify is server-only; the browser calls the rpc, and the rpc relays
to the channel.
app/pages/canvas/index.ts:
import canvas from "./template";
import { events } from "./services";
export default function route(req, res) {
return new canvas({ listener: events.listen() });
}
The route hands the template a fresh listener. Every browser on the page receives every event.
app/pages/canvas/template.html:
import "./style.css";
import type { Listener } from "@elements/app";
import { CanvasEvent, Stroke, broadcastStroke, broadcastClear } from "./services";
let canvasEl: HTMLCanvasElement | null = null;
let ctx: CanvasRenderingContext2D | null = null;
let currentStroke: Stroke | null = null;
function onInsert(el: HTMLCanvasElement) {
canvasEl = el;
ctx = el.getContext("2d");
}
function onStrokeBegin(x: number, y: number) {
if (!ctx) {
return;
}
currentStroke = { color: "#222", points: [{ x, y }] };
ctx.beginPath();
ctx.strokeStyle = currentStroke.color;
ctx.lineWidth = 2;
ctx.moveTo(x, y);
}
function onStrokeMove(x: number, y: number) {
if (!ctx) {
return;
}
if (!currentStroke) {
return;
}
currentStroke.points.push({ x, y });
ctx.lineTo(x, y);
ctx.stroke();
}
function onStrokeEnd() {
if (!currentStroke) {
return;
}
broadcastStroke(currentStroke);
currentStroke = null;
}
function onClear() {
broadcastClear();
}
function onIncoming(event: CanvasEvent) {
if (event.kind === "stroke") {
drawStroke(event.stroke);
} else {
clearCanvas();
}
}
function drawStroke(stroke: Stroke) {
if (!ctx) {
return;
}
if (stroke.points.length === 0) {
return;
}
ctx.beginPath();
ctx.strokeStyle = stroke.color;
ctx.lineWidth = 2;
ctx.moveTo(stroke.points[0].x, stroke.points[0].y);
for (let i = 1; i < stroke.points.length; i++) {
ctx.lineTo(stroke.points[i].x, stroke.points[i].y);
}
ctx.stroke();
}
function clearCanvas() {
if (!ctx) {
return;
}
if (!canvasEl) {
return;
}
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
}
<html class="canvas"
(listener: Listener<CanvasEvent>)
oninit={() => listener.on("notify", onIncoming)}>
<h1>shared canvas</h1>
<canvas width="800"
height="600"
oninsert={(e) => onInsert(e.target as HTMLCanvasElement)}
onpointerdown={(e) => onStrokeBegin(e.offsetX, e.offsetY)}
onpointermove={(e) => onStrokeMove(e.offsetX, e.offsetY)}
onpointerup={() => onStrokeEnd()}
onpointerleave={() => onStrokeEnd()}/>
<button onclick={() => onClear()}>clear</button>
</html>
Every function attached to an event attribute is named onX. Functions called
as helpers from inside the onX handlers (drawStroke, clearCanvas) use action
verbs. Module-level ctx, currentStroke, and canvasEl are set once on
onInsert and read by the rest.
onStrokeEnd is used by both onpointerup and onpointerleave. The latter
covers the case where the user drags off the canvas mid-stroke; the in-progress
stroke is finalized and broadcast. The handler's domain-concept name reads
correctly for both events.
The e.offsetX / e.offsetY coordinates are relative to the canvas element, so
coordinates are consistent across browsers regardless of viewport scroll or
layout differences.
Routes
Register the page in index.ts:
import canvas from "#app/pages/canvas";
// ...
app.route("/canvas", canvas);
Notes
- No persistence. A page refresh starts from a blank canvas. To persist, add
a
strokestable and write each stroke from the rpc inside atx()alongside thenotify. The route loads existing rows and the template draws them ononInsert. - Per-room canvases. Add
roomIdtoCanvasEventand passfilter: (event) => event.roomId === roomIdtoevents.listen({ ... }). The same channel handles every room; the filter runs server-side so a browser only receives events for its room. - Stroke compression. A 1-second stroke at 60 Hz produces 60 points. For low-end devices or shaky networks, run-length encode adjacent identical movements or downsample to every Nth point before broadcasting. The channel does no compression of its own.
- Color and brush size. Pass them in
Stroke. A small palette (Stroke.coloris just a string) and a brush-size slider on the page become local state plus a property on the stroke. The recipe uses a fixed color and line width to keep the focus on the channel. - Why no LiveTable. LiveTable is row-based: each stroke would be a row with optimistic insert, scope auto-fill, and broadcast on every mutation. The optimistic insert and auto-fill add nothing here (the browser already draws locally before sending), and the row storage actively gets in the way (clearing the canvas means deleting every row). The channel matches what the recipe actually does: broadcast an event, listen for events.