Architecture explainer · September 2026

How the drawing engine works

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.

4 × 5 live ink tiles 20 undo steps 1 op per frame 1.5 s idle before folding Worker PNG encode

1Start here

What a drawing engine has to do

Every drawing app, from a paint program to a toddler doodle app, solves the same four problems. The solutions differ. The problems do not.

1

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.

2

Make a line

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.

3

Show it fast

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.

4

Remember

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.

How Splotch answers them, in one paragraph

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.

A

Instructions, not pictures

Finger movement becomes small drawing instructions called ops. An op can be replayed at any size, which is what makes export and rotation possible.

B

Small surfaces paint faster

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.

C

Undo keeps "before" pieces

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

One stroke, start to finish

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.

Input: a finger touches the glass. A transparent full-size canvas on top of everything receives the pointer event, captures the pointer so the stroke cannot be stolen by a button, and converts the screen position into paper coordinates. In the lab, the small dots are the raw samples the browser delivered.
Show
Brush
Draw here with a finger, pen, or mouse
Samples 0 Ops 0 Tiles touched 0 / 20 Undoable 0 / 4 Folded into base 0
Historyempty

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

One conductor, several kinds of memory

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.

While drawing

  1. Pointer eventstouch · pen · mouse
  2. Enginecoordinates · fingers · tools
  3. Opsdot · path · clear
  4. Shared renderersame brush rules everywhere
  5. Live tilespixels on screen this frame

The same ops also go to

  1. Vector tailthe newest 20 commands + their patches
  2. Pixel baseolder commands, baked when idle
  3. Save workerpaper + texture + tiles + line art → PNG

The screen is one view of the drawing. It is not the only copy.

The layer stack you actually see

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.

✏️
Coloring-page line artA transparent image. Drawn above the ink so lines stay crisp over color.
☝️
Input canvasFull size for hit testing, but its bitmap is 1 × 1 pixel. It receives input and paints nothing.
Live ink tilesTwenty full-resolution canvases in a 4 × 5 grid. Hidden while empty. Each tile also owns two crayon canvases that the web build keeps hidden.
Paper sheetA CSS background color plus a small repeating paper texture image.

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

Many fingers, one undoable stroke

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.

t0
t1
t2
t3
t4
t5
t6
Finger A
down
dot
move
path
move
path
up
Finger B
down
dot
move
path
move
path
move
path
up
commit
t0 · Adown: dot, command opens
t1 · Bdown: dot, same command
t2 · Apath
t3 · Bpath
t4 · Aup, but B is still down
t5 · Bpath, then up
t6last finger gone: commit one command
One Undo removes both fingers' work. "All fingers down together" is the unit a toddler means by "that".
web/src/lib/drawing/engine.tsabridged
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();

From dots to a smooth line

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.

midpoint smoothingone curve segment per sample
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);

One op per frame, not one per event

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".

Pointer capture

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.

Resume detector

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.

Edge-swipe guard

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.

Pen-stream repair

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.

Cached measurement

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.

Speed window

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

Why the paper is cut into twenty pieces

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.

Cull first, paint second

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.

web/src/lib/drawing/tiledRenderer.tsthe hot decision
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);
}

How the grid size was chosen

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.

GridLargest surfaceCrayon lost framesMagic brushResult
3 × 30.547 Mpx93.8%not runCollapsed. Rejected after one capture.
4 × 40.308 Mpx0.96 – 1.18%passedThe previous grid. Over the 1% gate.
4 × 50.246 Mpx0.21 – 0.30%passedShipped. The only grid that cleared both brushes.
5 × 50.197 Mpx0.33 – 0.36%failedMore 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.

Live ink tiles

  • Attached to the page and presented by the browser.
  • Hidden whenever they contain no ink, so empty tiles cost the compositor nothing.
  • Tile edges are floored to whole device pixels before being converted back to CSS size, so neighbours butt exactly with no seam and no overlap.

Pixel-base tiles

  • Offscreen storage for strokes that are too old to undo.
  • Created only when the first fold happens.
  • Track a "painted" bit so a blank base tile is never copied.
  • Same 4 × 5 layout as the live tiles.

Knowing when the paper is blank

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

Vectors on top, pixels underneath

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.

LayerWhat it holdsLab label
Pixel baseCommands already baked into twenty offscreen tiles. Not undoable.in base
Waiting to foldVector commands past the undo limit, waiting for 1.5 s of idle.waiting
Undoable tailThe newest 20 commands: vector ops plus cropped "before" pixels per touched tile.patch
Active commandWhatever is being drawn right now, with temporary full-tile copies.active

The life of one command

Begin

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.

First touch of a tile

On non-blank paper, copy that whole tile once. Then grow the command's dirty rectangle with every op's padded bounds.

Commit

Crop each tile copy to its dirty rectangle and push the command. Keep at most 20 undoable.

Budget

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.

Wait

Any touch cancels folding. After 1.5 s without input, one command past the limit may fold. Then wait again.

Fold

Replay that command through the same renderer into the offscreen pixel base. Discard its vector ops.

Undo

Pop the newest command. Clear only its patch rectangles and paste the saved pixels back. That is one BLIT per tile.

Fallback

If the tile size changed or another finger is still down, replay history instead so the pixels stay correct.

web/src/lib/drawing/tiledRenderer.tsordinary undo, per touched tile
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.

Clear is one command spread across frames

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

Vectors, rasters, and what "BLIT" means

A fast copy of a rectangle of pixels.

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.

Source rectangle
Destination gets the same pixels

"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 appearWhat is rasterized or copiedWhy
Live tileA dot or path op becomes pixels at once.The child must see ink under the finger this frame.
Undo patchThe tile's pixels are copied before the command changes them, then cropped.Undo can restore them without replaying history.
Pixel baseAn old vector command is replayed into an offscreen tile.Keeps the amount of vector history bounded.
Magic sheetA 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.
ExportFinished 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.

The four op kinds

dot

A filled circle where a finger touched down. Carries color, radius, and brush flags.

path

A start point plus midpoint-smoothed curve segments, a width, the owning pointer id, and brush flags.

crayonFlush

Marks the end of a crayon pass. What it does depends on the crayon pipeline for that build.

clear

Wipe 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

One geometry pipeline, four paint styles

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.

Eraser

The same shape with destination-out. It removes alpha instead of adding color, so the paper shows through.

Magic

Fills the shape with a pattern cut from a hidden color sheet: the coloring page's colored version, or a rainbow on blank paper.

Crayon

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.

Magic: paint through a color sheet

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.

  • Each tile crops its own pattern. WebKit samples a full-page pattern slowly, so every tile gets a pattern source cut to its own rectangle and offset to line up.
  • The first paint captures the sheet. A Magic op remembers which sheet it painted from. Changing pages later does not silently recolor old ink.
  • Page changes recode. Applying or removing a coloring page recodes the retained Magic ops to the new sheet as one undoable command. Ink that has already folded into the pixel base is rebuilt from a kept raster baseline plus the folded ops, in draw order.
  • Not ready yet means paint nothing. An op created before its sheet arrives paints nothing, and one repaint resolves it when the sheet lands.
  • Rainbows are pooled. Ten random gradients are generated up front, each with a random angle, 5 to 8 stops, and a hue sweep that wraps past 360° so every rainbow misses a different color.

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

Coordinates, sharpness, rotation, and recovery

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.

  1. Browser client x/yCSS pixels in the viewport
  2. Backing x/ycached rect × render scale
  3. Inverse viewundo the contain-fit transform
  4. Paper x/ystored in every op
  5. Tile-local x/ypaper − tile origin

The paper locks when it has ink

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.

Paper adopted in portrait
Same pixels, fitted in landscape

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.

Two scales

Live resolution

  • renderScale = min(devicePixelRatio, 2)
  • Fixed for the whole engine session.
  • Stroke widths start in CSS pixels and scale once.
  • A 3× phone draws at 2×. Nobody can see the difference under a finger, and it halves the pixel count.

Export resolution

  • exportScale = max(devicePixelRatio, 2)
  • A 1× laptop still saves a 2× picture.
  • When the two scales match, the saved picture reuses the live tiles.
  • When they differ, the vector history is replayed at export scale.

The engine starts before the page is interactive

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.

When the browser throws the pixels away

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

Saving without freezing the drawing

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.

  1. Snapshotsynchronous, before any await
  2. Tile bitmapsonly visible, settled tiles
  3. Worker composesOffscreenCanvas at export scale
  4. Stackpaper → texture → tiles → line art
  5. PNG encode15 s timeout, one retry on context loss
web/src/lib/drawing/engine.tsthe order is a correctness rule
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.

Fast path

  • No finger is down.
  • Live scale equals export scale, which in practice means a 2× or higher display.
  • createImageBitmap, Worker, and OffscreenCanvas exist.
  • Only the worker ever owns a full-page canvas.

Compatibility path

  • A stroke is in progress, the scales differ, or an API is missing.
  • Replay the pixel base and the vector tail into an export surface.
  • Encode in the worker when possible, else on the main thread.
  • Same compositor rules, so the result looks the same.

Preview thumbnail

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.

Cooldown

Mobile Safari needs time to reclaim full-page surfaces. The camera button waits 4 seconds between saves.

Save to a folder

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

What the measurements said

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.

480 → 16 msdrawing paint time, 95th percentile, on the physical iPad. The worst frame fell from 696 to 32 ms.
417 → 0 msper second of drawing during which the screen showed nothing new. The "dead screen while scribbling" number.
1,780 → 10 msfrom an Undo tap to the next presented frame, 95th percentile.
~1,000 → 33 msnight-mode theme switch with a full drawing on screen.
401 → 25 msworst main-thread block while saving a picture.
141 → 28 msworst Magic-brush frame after the audio and blank-patch fixes.

Platform facts you cannot see from a laptop

2.3M

The canvas flush cliff

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.

163

toBlob blocks WebKit

The 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.

60

Safari's hidden refresh cap

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.

GPU

The desynchronized trap

The 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.

200×

Never read a big canvas per op

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.

97%

Reading a composited canvas per op freezes the page

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.

Working code, retired by a number

Replay-based undo

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.

Web-font preload

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.

Pre-baked paper texture tiles

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.

Crayon on WebGL

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.

Isolated crayon strokes

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.

Fully subtractive crayon

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

The words, explained

Each entry says what the thing is, why it matters here, and where it shows up in Splotch. Written in plain technical English.

Anti-aliasing
The browser makes the edge of a curve look smooth by painting partly transparent pixels along it. Those edge pixels reach slightly outside the exact shape. Splotch adds 2 px to every bounding box so culling and undo patches include them.
Animation frame
The browser presents a new image to the screen at a steady rate, usually 60 times a second. 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.
Backing store
The real bitmap behind a canvas, measured in device pixels. It can be a different size from the canvas's CSS size on screen. Splotch's input canvas has a 1 × 1 backing store but a full-size CSS box.
BLIT
Short for "bit block transfer". A copy of a rectangle of existing pixels from one bitmap to another, with no drawing math. In Canvas 2D it is a drawImage call with a canvas or bitmap as the source. Splotch uses BLITs for undo, folding, and export.
Canvas
An HTML element that holds a bitmap you can draw on with JavaScript. The "2D context" is the drawing API: paths, fills, strokes, transforms, and compositing rules. Splotch uses about sixty on-screen canvases and twenty offscreen ones.
Coalesced events
Touch hardware samples position more often than the browser fires 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.
Command
One undoable unit. In Splotch it is every finger that was down at the same time, from the first touch to the last lift. A Clear is also one command.
Compositing
The rule for combining new pixels with the pixels already there. The rule is set by globalCompositeOperation. Splotch uses source-over for pen and Magic, destination-out for the eraser, and darken for crayon mixing.
Contain-fit
Scale something by 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.
Context loss
The operating system can take a canvas's graphics memory away, usually when the app is in the background. The pixels and the drawing state are gone when it comes back. Splotch listens for the browser's loss and restore events, probes for silent resets on resume, and repaints from history.
Culling
Skipping work that cannot affect the result. Here it means skipping every tile whose rectangle does not touch an op's padded bounding box. A short stroke usually touches one or two tiles out of twenty.
Destination-out
A compositing rule where the new shape removes alpha from what is already there instead of adding color. It is how the eraser works: the paper shows through the hole.
Device pixel ratio
How many physical screen pixels make up one CSS pixel. A phone with a ratio of 3 has three hardware pixels per CSS pixel in each direction. Splotch draws at no more than 2 and saves at no less than 2.
Dirty rectangle
The smallest box that safely contains every pixel a command may have changed. Splotch grows it as ops arrive and uses it to crop undo patches. It is conservative: a little too big is fine, too small is a bug.
Fold
Take an old vector command, paint it into the offscreen pixel base, and delete its vector form. This bounds how much history is kept. Folding only runs after 1.5 s of no input, one command at a time.
Frame and frame budget
One image presented to the screen. At 60 Hz there are 16.7 ms between frames, and all input handling, drawing, and browser work for that frame must fit inside it. A frame that misses is a "lost frame" and shows up as a stutter.
Hydration
When a prerendered HTML page becomes interactive because the JavaScript framework attaches to it. It can take a few hundred milliseconds. Splotch starts the engine before hydration so the first touch is never lost.
ImageBitmap
A decoded picture that can be handed to another thread without copying. Splotch transfers tile bitmaps to the export worker and Magic sheets back from the sheet worker this way.
Main thread
The single thread that runs page JavaScript, handles input, and lays out the page. Anything slow on it delays the next frame. Splotch moves PNG encoding and sheet rasterization to workers to keep it free.
OffscreenCanvas
A canvas that is not in the page and can live on a worker. The export worker builds the full-page picture on one so the main thread never owns a tablet-sized surface.
Op (operation)
One drawing instruction. Splotch has four kinds: dot, path, crayonFlush, and clear. A path op holds a start point, a list of curve segments, a width, and brush flags. Ops are what history stores and what the renderer paints.
Paper space
The fixed coordinate system the drawing lives in, in device pixels of the paper as it was adopted. Ops, tiles, patches, and Magic sheets all use it. Rotation changes how paper space is shown, never what is in it.
Patch
A cropped copy of a tile's pixels from just before a command changed them. Undo pastes the patch back. Patches are cheap to restore but cost memory, so a byte budget drops the oldest ones.
Pattern
A canvas fill made from an image instead of a color. Magic strokes use a pattern cut from the color sheet, aligned so the picture appears in place. Crayon uses patterns of wax texture.
Pixel base
Twenty offscreen tiles that hold every command too old to undo, as plain pixels. It is the bottom layer of history. Live tiles show the base plus the vector tail plus whatever is being drawn.
Pointer capture
Telling the browser to keep sending a pointer's events to one element even when the pointer moves over something else. Without it, dragging across a button would end the stroke.
Pointer event
The browser's single event type for mouse, touch, and pen. Each contact has a pointer id, and the events are pointerdown, pointermove, and pointerup. Splotch handles all input through them.
P95
The 95th percentile. If paint time P95 is 16 ms, then 95 out of 100 frames painted in 16 ms or less. It describes the typical bad case rather than the average, which hides stutter.
Quadratic curve
A curve defined by a start point, one control point, and an end point. The line bends toward the control point without passing through it. Splotch draws one per input sample, from midpoint to midpoint, with the raw sample as the control point.
Raster, rasterize
A raster is a grid of colored pixels. To rasterize is to turn a shape description into that grid. Canvas backing stores, patches, the pixel base, and PNG files are all rasters.
Source-over
The default compositing rule: put the new pixels on top, using their transparency to blend with what is there. Pen and Magic use it.
Tile
One cell of the 4 × 5 grid that covers the paper. Each tile is its own canvas with its own backing store. Twenty live tiles are in the page; twenty base tiles are offscreen.
Transform
A small matrix that moves, scales, or rotates coordinates before drawing. Each tile's context carries a transform that subtracts the tile's origin, so an op in paper coordinates lands in the right spot on that tile.
Vector
A drawing stored as shapes and instructions rather than pixels. It can be redrawn at any size. Splotch's recent history is vector so it can be replayed for export at a different scale or after a recovery.
Worker
A separate JavaScript thread with no access to the page. It cannot block input or frames. Splotch uses one to rasterize Magic sheets and one to compose and encode PNGs.

Where to read the code

FileOwns
engine.tsPublic entry point, paper coordinates, the pointer map, multi-finger command boundaries, tool state, and the synchronous export snapshot.
strokeRasterQueue.tsQueues pointer samples and rasterizes them as one op per frame.
tiledRenderer.tsLive rendering, the vector tail, patch undo, idle folding, progressive clear, and context recovery.
liveTiles.tsThe 4 × 5 grid constants, imported by the renderer and the markup so they cannot disagree.
tiledSurfaces.tsLive canvases, hidden-tile lifecycle, and the offscreen pixel-base tiles.
strokeOps.tsThe op vocabulary and the shared renderer that paints an op onto any context.
tiledUndoPatches.tsFull-tile capture, dirty-rectangle union, cropping, byte accounting, and patch lookup.
magicBrush.tsColor sheet lifecycle, worker rasterization, per-op sheet capture, and per-tile patterns.
paperView.tsThe contain-fit math that presents locked paper after rotation.
exportDrawing.tsThe export fast path and compatibility path, and the shared compositor rules.
LiveSurface.svelteThe 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.

Decision records behind this page