# Collaborative Canvas A 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 ```bash 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`: ```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("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`: ```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`: ```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); } ) 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 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. The channel does no compression of its own. - **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, 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.