Read the finger
The browser sends pointer events many times a second. The engine has to know which finger each event belongs to, where it is on the paper, and when a finger has lifted.
Architecture explainer · September 2026
A drawing app has four jobs: read the finger, turn movement into a line, put pixels on screen before the next frame, and remember enough to undo and save. This page walks through each job, shows how Splotch does it, and lets you try the important parts.
1Start here
Every drawing app, from a paint program to a toddler doodle app, solves the same four problems. The solutions differ. The problems do not.
The browser sends pointer events many times a second. The engine has to know which finger each event belongs to, where it is on the paper, and when a finger has lifted.
Events are dots. A line needs a curve through them that looks smooth, keeps up with a fast scribble, and does not draw bridges across gaps.
Ink has to appear under the finger within one display frame. On a big tablet the slow part is not the drawing math. It is the browser pushing a huge changed surface to the screen.
Undo must put the old pixels back. Save must produce a full-size picture. Both have to work without freezing the drawing, and without keeping unlimited history in memory.
Splotch keeps the drawing in two forms at once. Recent strokes stay as vector instructions ("a blue path, 18 px wide, through these points"). Older strokes are baked into pixels. The visible paper is not one big canvas but a grid of twenty smaller ones, so a stroke only repaints the tiles it crosses. Before a stroke changes a tile, the engine copies the pixels that are about to change, so Undo is a paste rather than a replay. Saving hands the finished tiles to a worker thread, which assembles and encodes the PNG while the child keeps drawing.
Finger movement becomes small drawing instructions called ops. An op can be replayed at any size, which is what makes export and rotation possible.
The paper is split into a 4 × 5 grid. A curve crossing three tiles paints three small surfaces, not one tablet-sized one. This was the single biggest performance win.
Before a stroke changes a tile, the engine copies it. When the stroke ends, the copy is cropped to the changed rectangle. Undo pastes it back.
Reading tips. Dotted-underlined words link to the glossary at the end. A floating button brings you back. The crayon brush has its own texture pipeline and is described on a separate page, so here it appears only as one of the four brushes.
2Try it
Pick a stage to read what happens, then draw on the paper below to watch it happen. The lab runs a simplified copy of the real engine's rules: the same 4 × 5 grid, the same midpoint curves, the same one-op-per-frame batching, and the same "copy before you change it" undo. The lab keeps 4 undo steps so folding is visible quickly. The real app keeps 20.
One command per stroke. After 1.5 s of no input, the oldest command past the undo limit folds into the pixel base and loses its patch.
3The parts
The engine is a plain TypeScript module, not a Svelte component. The UI pushes tool choices in and receives a few callbacks out. Pointer handling and canvas pixels never go through the reactive render loop, because a reactive update per pointer event would be far too slow.
The screen is one view of the drawing. It is not the only copy.
From top to bottom, the drawing area is four DOM layers. The warm paper is CSS, not a canvas. Only the ink and the pixel base use the tile grid.
Not in the DOM: twenty more offscreen canvases hold the pixel base, the baked older strokes. They use the same 4 × 5 layout so a tile can be folded into its base tile with one copy.
4Input
Each finger on the glass gets its own record: position, last midpoint, width, brush flags, and a small speed history. But every finger that is down at the same time writes into one shared command. Lifting one finger does not end the command. Lifting the last one does.
const activePointers = new Map<number, PointerState>();
function beginStrokeGroup() {
if (groupHasDrawn) return;
beginTiledCommand(canvasEmpty);
groupHasDrawn = true;
}
// A finger lifting does not commit while another finger remains.
activePointers.delete(event.pointerId);
if (activePointers.size === 0) finishStrokeGroup();
The browser usually delivers one pointermove per display frame, but the touch hardware samples faster than that. The extra samples are available through getCoalescedEvents(), and Splotch reads all of them. A fast scribble on an iPad produces about two to four samples per frame.
For each sample, the engine draws a quadratic curve from the previous midpoint to the new midpoint, using the previous raw sample as the control point. Neighbouring curves share a tangent where they meet, so corners look smooth with no curve-fitting step. Turn on "Straight chords" in the lab to see what the raw samples look like without this.
for (const { x, y } of points) {
const midX = (pointer.x + x) / 2;
const midY = (pointer.y + y) / 2;
op.segs.push({ cx: pointer.x, cy: pointer.y, x: midX, y: midY });
pointer.x = x; pointer.y = y;
}
// later, when the op is painted:
context.quadraticCurveTo(seg.cx, seg.cy, seg.x, seg.y);
Painting inside the event handler would repaint the same frame two to four times, and every pass pays the per-tile cost again. Instead the engine queues the samples and drains them once per animation frame into a single path op. The pixels are identical, because the op still carries every sample as its own curve segment. The per-op bookkeeping is paid once. Watch the "Ops" counter in the lab grow more slowly than "Samples".
On the first touch, the input canvas captures the pointer. The stroke keeps flowing even if the finger crosses a floating button or leaves the canvas edge.
A gap over 100 ms combined with a jump over 10% of the paper's short side is treated as a new contact, not a continuation. This stops a stray line bridging two separate touches.
A touch that starts within 24 px of a system gesture edge is buffered for its first 12 px of travel. An inward swipe is discarded as a system gesture. Anything else becomes ink.
WebKit sometimes starts an Apple Pencil stroke with a move and no pointerdown, after a fast tap. The engine tracks every pointer that went down anywhere in the page and adopts a pen move whose id it never saw.
The canvas rectangle is measured once and cached, so the pointer path never forces the browser to lay out the page. A rectangle with zero area is refused, because adopting one would silently collapse the paper to nothing.
A 100 ms sliding window of distance drives the volume of the drawing sound. It reacts to real speed changes without twitching on every tiny jitter.
Style is frozen per finger. Color, width, eraser, Magic, and crayon flags are copied into the finger's record when that finger touches down. Changing the color mid-stroke with another hand does not rewrite ops that are already painted.
5Tiles
On a large iPad, the JavaScript finished painting quickly and the screen still froze for hundreds of milliseconds. The browser was flushing one huge changed canvas to the display. The variable that mattered was the size of the largest surface that changed, not the total number of pixels painted. Splitting the paper into a grid of smaller canvases kept the same resolution and removed the freeze.
Every op carries paper-space geometry. Before painting, the renderer computes a box around the op's points and control points, pads it by half the stroke width plus a 2 px anti-aliasing bleed, and tests that box against each tile. This is culling. Only tiles the box touches allocate memory, get an undo copy, become visible, and run the renderer. Each tile's drawing transform subtracts the tile's origin, so every op stays in one global paper coordinate system.
for (const [index, tile] of liveTiles.entries()) {
if (!geometryIntersectsTile(op, tile)) continue;
ensureNormalTileBacking(tile);
prepareTileForMutation(tile, index);
if (command) undoPatches.capture(command, tile, index, opDeviceBounds(tile, op));
renderOp(tile.ctx, op);
}
The grid started as 4 × 4. A later sweep on the physical iPad compared one smaller and two larger grids, using the crayon brush because it stresses surfaces the most. Each row is three captures of the same gesture. "Lost frames" is the share of frames the screen failed to present on time.
| Grid | Largest surface | Crayon lost frames | Magic brush | Result |
|---|---|---|---|---|
| 3 × 3 | 0.547 Mpx | 93.8% | not run | Collapsed. Rejected after one capture. |
| 4 × 4 | 0.308 Mpx | 0.96 – 1.18% | passed | The previous grid. Over the 1% gate. |
| 4 × 5 | 0.246 Mpx | 0.21 – 0.30% | passed | Shipped. The only grid that cleared both brushes. |
| 5 × 5 | 0.197 Mpx | 0.33 – 0.36% | failed | More tiles means more surfaces to visit per op. Magic regressed. |
The slow 4 × 4 frames carried at most 1 ms of engine JavaScript. The time was in the browser's own surface work, which is why the fix was topology rather than code.
Undo, Clear, and the camera button disable themselves when the paper is empty. Checking that means reading pixels back, which is slow on a GPU-backed canvas and gets slower if you do it often. So each tile is copied at quarter size onto one small CPU-side scratch canvas and scanned there. Even that costs a few milliseconds across twenty tiles, so the scan waits until 400 ms after the last input rather than running during a stroke.
6Undo
Undo is a hybrid. Recent commands are kept as vector ops and own cropped pixel patches of what was under them. Old commands lose their patches, then fold into the pixel base while the child is not drawing. The lab's history strip shows the same three states.
| Layer | What it holds | Lab label |
|---|---|---|
| Pixel base | Commands already baked into twenty offscreen tiles. Not undoable. | in base |
| Waiting to fold | Vector commands past the undo limit, waiting for 1.5 s of idle. | waiting |
| Undoable tail | The newest 20 commands: vector ops plus cropped "before" pixels per touched tile. | patch |
| Active command | Whatever is being drawn right now, with temporary full-tile copies. | active |
The first pixel of the stroke group records whether the paper was blank and opens one active command. A command that began on blank paper captures no patches at all. Undo just blanks its tiles.
On non-blank paper, copy that whole tile once. Then grow the command's dirty rectangle with every op's padded bounds.
Crop each tile copy to its dirty rectangle and push the command. Keep at most 20 undoable.
If all patches together exceed six papers' worth of bytes, drop the oldest patch. That command stops being undoable and joins the waiting row. The newest two commands always keep their patches.
Any touch cancels folding. After 1.5 s without input, one command past the limit may fold. Then wait again.
Replay that command through the same renderer into the offscreen pixel base. Discard its vector ops.
Pop the newest command. Clear only its patch rectangles and paste the saved pixels back. That is one BLIT per tile.
If the tile size changed or another finger is still down, replay history instead so the pixels stay correct.
tile.ctx.save();
tile.ctx.setTransform(1, 0, 0, 1, 0, 0);
tile.ctx.clearRect(snapshot.x, snapshot.y, snapshot.canvas.width, snapshot.canvas.height);
tile.ctx.drawImage(snapshot.canvas, snapshot.x, snapshot.y); // the restoring BLIT
tile.ctx.restore();
Why patches instead of replay? Replaying twenty commands across all tiles took about 1.8 seconds on the physical iPad. A cropped patch paste takes about 0 to 1 ms. Undo only ever removes the newest command, so a patch is always correct: nothing newer can have changed those pixels.
Clearing many full tiles and copying them all in one go caused long frames. Clear now hides the affected tiles immediately, so it looks instant. Then it copies at most one tile per animation frame for the undo patch, and wipes each hidden tile two frames after its copy. An Undo that arrives mid-way either restores the finished patches or simply unhides the tiles that were never wiped.
7Pixels
The word comes from "bit block transfer". In a Canvas 2D engine it is usually a drawImage(sourceCanvas, …) call that copies pixels that already exist from one canvas into another. No curve math runs. No brush logic runs. It is the cheapest thing a canvas can do, as long as the source is small.
"Vector" does not mean "never turned into pixels". Recent history stores ops such as "a quadratic path, 18 px wide, blue". The moment the renderer paints that op onto a canvas, the browser rasterizes it into pixels. Splotch keeps both forms on purpose, at different stages.
| Where pixels appear | What is rasterized or copied | Why |
|---|---|---|
| Live tile | A dot or path op becomes pixels at once. | The child must see ink under the finger this frame. |
| Undo patch | The tile's pixels are copied before the command changes them, then cropped. | Undo can restore them without replaying history. |
| Pixel base | An old vector command is replayed into an offscreen tile. | Keeps the amount of vector history bounded. |
| Magic sheet | A coloring fill image or a rainbow is rasterized to a paper-sized bitmap, usually in a worker. | Magic strokes can fill from one stable, non-repeating pattern. |
| Export | Finished tile bitmaps are BLITed into a full-page canvas in a worker, then encoded as PNG. | Keeps the big full-page surface off the UI thread. |
dotA filled circle where a finger touched down. Carries color, radius, and brush flags.
pathA start point plus midpoint-smoothed curve segments, a width, the owning pointer id, and brush flags.
crayonFlushMarks the end of a crayon pass. What it does depends on the crayon pipeline for that build.
clearWipe the target. Because it is an op, a Clear is undoable like any stroke.
Brush flags on a dot or path: erase, magic with its captured sheet, and crayon with its texture seed.
8Brushes
Every brush produces the same dot and path ops. The flags on the op decide how the shared renderer turns that shape into pixels. This is what keeps undo, export, and rotation brush-agnostic.
Solid color with source-over compositing. Stroke or fill the shape directly. The simplest route.
The same shape with destination-out. It removes alpha instead of adding color, so the paper shows through.
Fills the shape with a pattern cut from a hidden color sheet: the coloring page's colored version, or a rainbow on blank paper.
Paints a wax texture and mixes it with the color underneath. Its pipeline differs between the web build and the native app, and it has its own page.
The colored version of a coloring page, or a generated rainbow, is fitted into paper space to make a "sheet". Where the platform allows it, a worker fetches, decodes, and rasterizes that sheet and hands back an ImageBitmap. A Magic stroke does not paint a color. It paints a window onto the sheet, so scribbling reveals the picture underneath.
Crayon, in one sentence. Crayon paints a fixed wax texture through the same ops, and mixes new wax with what is underneath using a darken-then-blend rule. On the web it restamps each op onto the ink tile from an offscreen shadow; in the native app it applies the mix per op directly. Both were chosen by measurement on the physical iPad, and the full story lives on the crayon page.
9Paper space
Ops are never tied to the phone's current orientation. They live in paper space, a fixed pixel coordinate system adopted from the viewport when drawing starts. The screen is a view onto that paper.
On a blank canvas, a resize adopts the new viewport size. Once there is ink, a rotation keeps the old paper size. The paper sheet, the line art, and the whole tile wrapper receive one CSS contain-fit transform that scales and centers them in the new viewport. Pointer input goes through the inverse of the same matrix. No tile is resized and no ink is replayed. Rotating back reveals the exact original pixels.
Why CSS and not canvas resizing? Resizing every live canvas on rotation produced frames of 56 to 57 ms on the physical iPad. Presenting the existing pixels through one CSS transform brought the worst frame to about 20 to 31 ms.
renderScale = min(devicePixelRatio, 2)exportScale = max(devicePixelRatio, 2)The home page is prerendered HTML. As soon as the engine module loads, it finds the existing input canvas and starts listening for pointers. Later, when Svelte hydrates, the drawing component adopts the running engine and wires up its callbacks. Without this, the canvas looked ready for several hundred milliseconds while it could not draw.
A backgrounded tab or app can lose its canvas contents and drawing state. This is context loss. An Android phone came back from the background with a blank paper where new ink appeared only as small clipped fragments, because each tile's transform had reset to identity while the app kept running. The engine now listens for the browser's loss and restore events on every surface, and on every resume it also probes each context for its expected round line caps and tile transform, since some resets are silent. Recovery rebinds all contexts, reapplies their state, and repaints from the pixel base plus the vector tail.
10Save
The saved PNG contains the paper color, the paper texture, the ink, and the coloring-page line art. Assembling a full-size page and encoding it is slow, so the whole job runs on a worker whenever the browser supports the pieces.
const scale = currentExportScale();
// Must happen before loading the export module.
const snapshots = snapshotStrokes(scale, capturePreview);
const { composeExportPng } = await import('./exportDrawing');
return composeExportPng(snapshots.export, scale, overlaySource, options);
Taking the snapshot before the first await is what makes save-on-delete safe. The Clear button starts an export and wipes the paper in the same tap. Because the snapshot is already taken, the saved picture is complete even though the live tiles are empty by the time the worker runs.
createImageBitmap, Worker, and OffscreenCanvas exist.The polaroid animation needs a small image fast. It is downscaled from the worker's own composition rather than decoded from the finished PNG. That won a device race: 24 ms against 49 ms.
Mobile Safari needs time to reclaim full-page surfaces. The camera button waits 4 seconds between saves.
On desktop Chromium a chosen folder handle is stored in IndexedDB, the only storage that can hold it. A one-bit flag in localStorage says whether a folder exists, so the common case never loads that code.
11The numbers
Almost everything above exists because a physical iPad said the previous design was too slow. Each change landed with before-and-after captures on the real device, driven by trusted synthetic touch and scored from a recording of the screen itself. Emulators and headless browsers passed while the real screen froze.
On the target iPad, changing one large canvas starved the display. The cliff sat between roughly 1.6 and 2.3 megapixels per surface for that device. That is an observed bound, not a WebKit constant. Twenty tiles sit well below it.
toBlob blocks WebKitThe spec says PNG encoding runs in parallel. WebKit spent 163 to 206 ms on the main thread. Moving encodes off the thread cut a 2,390 ms stroke-end freeze to 1 ms, and is why undo never encodes anything: over budget, it drops old patches instead.
requestAnimationFrame runs at 60 Hz on a 120 Hz iPad. The real budget is 16.7 ms per frame, not 8.3. This was only visible by scoring a capture of the actual screen.
desynchronized trapThe low-latency canvas hint passed every headless gate, then rendered the canvas solid black on a real Android WebView. A hardware overlay plane does not blend with the DOM underneath it.
drawImage from a large freshly painted canvas forces a full copy of that surface. Measured at about 200 times the cost of a pattern fill. Composite small layers onto big ones, never the reverse.
One crayon experiment read the on-screen tile after every op. It lost 97% of frames, because each read forces the GPU pipeline to drain. Reads happen at most once per pass, off the input path.
Small and fast in June: kilobytes of history, 0.1 ms undos. Retired in July: the first textured brush broke the "replay gives identical pixels" contract, and crayon commits stalled for 1.2 to 2.2 seconds. Pixel patches won. Only the byte budget survived.
It worked: font fetch fell from 1,950 to 911 ms. It still lost, because it cost about 140 ms of first paint on a route that draws no text, by taking bandwidth from the paper texture.
A finished, green implementation measured 0.49 → 0.50 ms in light mode and 0.58 → 0.53 ms in dark mode. A null result that disagreed with itself. About 350 lines closed unmerged.
A GPU renderer for the wax texture was built and measured. Its cost tracked the size of the render target, not the amount drawn, so it carried the same large-surface problem the tiles were built to avoid. Recorded as a dead end rather than shipped.
Compositing each textured stroke once would have allowed soft, partial-alpha wax. It measured about 6× worse to draw and 100× worse to undo. Rejected in the same pull request that measured it.
Pure "darken" mixing is free to paint per op, because applying it twice changes nothing. A human drew with it and rejected it at once: blue over yellow stays green forever, and the crayon feels like it refuses to work.
The method is the moral. Every fix on this page was a single-variable trial on the physical device, each backed out before the next. The tile grid alone took forty-four such trials. Input came from trusted automation at 100 to 170 moves per second, and results were scored from a screen recording, never from what JavaScript reported about itself.
12Glossary
Each entry says what the thing is, why it matters here, and where it shows up in Splotch. Written in plain technical English.
requestAnimationFrame runs your code once before each frame. Splotch drains queued samples, captures clear patches, and warms caches one step per frame so no single frame does too much.drawImage call with a canvas or bitmap as the source. Splotch uses BLITs for undo, folding, and export.pointermove. The skipped samples are attached to the event and available through getCoalescedEvents(). Reading them keeps fast scribbles curved instead of turning them into long straight chords.globalCompositeOperation. Splotch uses source-over for pen and Magic, destination-out for the eraser, and darken for crayon mixing.min(boxWidth / paperWidth, boxHeight / paperHeight) so all of it fits inside a box, then center it. The same matrix presents the paper after rotation and maps pointer input back onto it.pointerdown, pointermove, and pointerup. Splotch handles all input through them.| File | Owns |
|---|---|
| engine.ts | Public entry point, paper coordinates, the pointer map, multi-finger command boundaries, tool state, and the synchronous export snapshot. |
| strokeRasterQueue.ts | Queues pointer samples and rasterizes them as one op per frame. |
| tiledRenderer.ts | Live rendering, the vector tail, patch undo, idle folding, progressive clear, and context recovery. |
| liveTiles.ts | The 4 × 5 grid constants, imported by the renderer and the markup so they cannot disagree. |
| tiledSurfaces.ts | Live canvases, hidden-tile lifecycle, and the offscreen pixel-base tiles. |
| strokeOps.ts | The op vocabulary and the shared renderer that paints an op onto any context. |
| tiledUndoPatches.ts | Full-tile capture, dirty-rectangle union, cropping, byte accounting, and patch lookup. |
| magicBrush.ts | Color sheet lifecycle, worker rasterization, per-op sheet capture, and per-tile patterns. |
| paperView.ts | The contain-fit math that presents locked paper after rotation. |
| exportDrawing.ts | The export fast path and compatibility path, and the shared compositor rules. |
| LiveSurface.svelte | The tile canvases in the DOM, generated from the grid constants. |
A fork to know about. undoHistory.ts and the /dev/engine harness keep the older single-canvas renderer for tests. They do not describe production undo or the tile grid.