2026-05-29devlogScratchpadby Roman Sanine

Scratchpad: Refactoring a Canvas Engine to Its Core

multiplayerarchitecturecanvaswebsocketrefactoring

Scratchpad is a real-time collaborative drawing canvas — multiplayer strokes over a Go WebSocket server, a Paper.js vector engine on the client, and a gesture-recognition layer that snaps freehand input into clean shapes. The full picture lives on the project page. This entry is about the un-glamorous work that made the rest sustainable: the last stretch wasn't features, it was carving the canvas engine down to a modular core.

Why refactor, not ship

Canvas code rots toward god-objects. A single `CanvasTools` module had accreted state, color management, debug helpers, selection, image import, persistence, viewport math, and an SVG overlay — all tangled together. New tools meant editing the monolith and praying. So the goal was structural: extract cohesive units, delete what no longer earns its place, and make the boundaries (viewport / tools / drawing / sync) legible again.

What changed

  • Decomposed the monolith. Pulled `canvasState`, `canvasColors`, and `canvasDebug` out of `CanvasTools`, then extracted `useCanvasSelection`, `useCanvasImageImport`, and dedicated composables for persistence, viewport, and the SVG overlay. The canvas core is now a set of focused composables instead of one file.
  • Split shape detection per shape. `shapeDetection.ts` became per-shape modules — each detector isolated, testable, and independently improvable.
  • Consolidated utilities. Merged `utils.ts` into `lib/canvas/paperUtils.ts` and unified duplicated date formatters into a single `utils/dateFormat`. One home per concern.
  • Deleted dead subsystems. Removed the legacy OCR system, an unused `useScreenLog` composable, orphaned components, and unused dependencies — plus the experimental gesture/Gemini scaffolding that never made the cut. Scope boundaries (no OCR/ML/handwriting) are now reflected in the code, not just the docs.
  • Extracted the gesture surface. Lifted `useGestureCollection`, `useGestureMetrics`, and a `GestureTable` component out of the gestures pages so recognition logic stops living in view code.

A real bug, not just tidying

Refactoring surfaced a genuine defect: an inverted center tangent in the Bézier curve fitting. Freehand strokes fit to curves via tangent estimation, and a flipped sign at the center control point bent smoothed strokes the wrong way. Fixed — smoothed curves now follow the input. (Also retired the removed `HandwriteTool`, defaulting the toolbar to the Diagram tool.)

Where it stands

Live at pad.exesiv.dev. The engine is leaner, the layers are separable, and adding a tool no longer means surgery on a 1,000-line file. Dead weight — OCR, unused ML experiments, orphaned utils — is gone.

Next steps

  • Land tests against the new per-shape detection modules now that they're isolated.
  • Tighten the WebSocket reconnect/resync path so a dropped client rejoins to the exact current canvas, not a stale snapshot.
  • Document the gesture-set training flow end-to-end (define → wizard → score) for new users.

Looking forward

The bet behind a cleanup-heavy stretch is leverage: a canvas app earns its keep at the seams between viewport, tools, drawing, and sync. With those seams clean, the interesting work — better gesture recognition, richer real-time collaboration — gets cheaper to build instead of more expensive. Scratchpad is now in a state where features add value without adding entropy.

Comments
No comments yet.
Comments are reviewed before appearing.