# Collaborative Canvas A shared drawing canvas where every browser, on every app server, 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. Each page load starts from a blank canvas and draws every stroke from then on. 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 ```bash Terminal 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 `template.ehtml` rather than under `app/shared/`. ```ts app/pages/canvas/index.ts import { Request, Response } from "@elements/app"; import canvas, { events } from "./template"; export default function route(req: Request, res: Response) { return new canvas({ listener: events.listen() }); } ``` `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. The route hands the template a fresh listener. Every browser on the page receives every event. ```ehtml app/pages/canvas/template.ehtml import "./style.css"; import { Channel, type Listener } from "@elements/app"; export interface Point { x: number; y: number; } export interface Stroke { color: string; points: Point[]; } export type CanvasEvent = | { kind: "stroke"; stroke: Stroke } | { kind: "clear" }; export const events = new Channel("canvas"); /** @rpc */ export function broadcastStroke(stroke: Stroke) { events.notify({ kind: "stroke", stroke, }); } /** @rpc */ export function broadcastClear() { events.notify({ kind: "clear" }); } 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); } , ) oninit={() => listener.on("notify", onIncoming)}>

shared canvas

onInsert(e.target as HTMLCanvasElement)} onpointerdown={(e) => onStrokeBegin(e.offsetX, e.offsetY)} onpointermove={(e) => onStrokeMove(e.offsetX, e.offsetY)} onpointerup={() => onStrokeEnd()} onpointerleave={() => onStrokeEnd()}/>
``` 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`: ```ts 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 `strokes` table and write each stroke from the RPC inside a `tx()` alongside the `notify`. The route loads existing rows and the template draws them on `onInsert`. - **Per-room canvases.** Add `roomId` to `CanvasEvent` and pass `filter: (event) => event.roomId === roomId` to `events.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. - **Color and brush size.** Pass them in `Stroke`. A small palette (`Stroke.color` is 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, partition auto-fill, and broadcast on every mutation. The optimistic insert and auto-fill add nothing here (the browser already draws locally before sending), and a channel models clearing the canvas as one event rather than a delete per row. The channel matches what the recipe actually does: broadcast an event, listen for events.