Course progress Course outline 18 of 18 lessons available
Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates
Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools
- 05 Chapter 5: Give the Pixel World a Registry available now
- 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
- 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
- 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
- 09 Chapter 9: Tools Are Traffic Lights, Not a Bag of Booleans available now
Part III: From “It Drags” to “It Is Trustworthy”—Interaction, Text, Assets, and Recovery
- 10 Chapter 10: Make the Editor Feel Right available now
- 11 Chapter 11: Drawn Text Is Not Editable Text available now
- 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
- 13 Chapter 13: Time Machines and Old Boxes available now
- 14 Chapter 14: Looking Correct Is Not Being Correct available now
Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI
Start with a game a five-year-old can understand
Find two cookies, one strawberry, and a sheet of paper. The first time you make a “smiley cake,” arrange everything however you like, then ask a friend to clear the plate. Now tell them only, “Put back that little bit from before.” They do not know where “that little bit” belongs. The second time, write a recipe first: wipe the plate clean; place two cookies at (30, 20) and (70, 20); place the strawberry at (50, 55); finally draw the mouth. After clearing the plate, give the recipe to another friend. They can rebuild the smile too.
First predict: if the step “pour translucent syrup” does not say “switch back to ordinary syrup afterward,” what happens to the next cookie? If “sprinkle sugar only inside the round plate” has no ending marker, what happens to decorations outside the plate?
- Read the whole recipeObjects and camera
- Wipe the plate cleanDo not rely on old colors
- Arrange in layersBackground → shapes → hints
- Compare resultsThe same every time
The single truth of this chapter is: a reliable Canvas Renderer must be able to rebuild the entire scene from data. The target interface is render(model, camera, context), not “the user dragged, so paint one more patch over the old pixels.”
Translate the toys into Canvas engineering
| Cake-game concept | Canvas engineering | Purpose |
|---|---|---|
| Ingredient list | Document Model | Stores facts |
| View of the arrangement | Camera | Determines how the world is projected |
| Recipe | Pure Renderer | The same input produces the same drawing commands |
| Wiping the plate | clear/background pass | Removes the old Bitmap |
| Mold outline | Path / Path2D | Describes a geometric boundary |
| Syrup and outlines | Fill / Stroke | Colors the inside or edge of a path |
| Putting on and removing gloves | save() / restore() | Isolates Transform, Alpha, Clip, and styles |
| Chefs with separate jobs | Render Pass | Orders background, grid, shapes, and Overlay |
The analogy breaks here: Canvas’s current path is not fully part of the saved-state stack, so do not assume save() clears paths created before beginPath(). Path2D is a reusable geometry container, but it is not a business object with a stable ID in the Document. Browser fonts, image decoding, and antialiasing also mean cross-platform pixels may not be bit-for-bit identical. Determinism therefore requires validation both at the “command order/model result” level and at the “visual result within an allowed tolerance” level.
Kill the misleading intuitions first
- “During a drag, erasing the old rectangle and drawing a new one is fastest.” A grid, lines, or transparent layers may be underneath the old rectangle. Without a rigorous dirty-region algorithm, local patching leaves ghosts. The teaching kernel rebuilds the full scene first.
- “
save()automatically pairs withrestore().” It does not. An exception or early return leaks state, so usetry/finally. - “Every Shape can change the Context however it likes.” One Shape’s alpha, clip, transform, or lineJoin contaminates every subsequent Shape. The Registry must establish isolation boundaries.
- “If it looks the same, it is deterministic.” Random numbers, current time, unloaded fonts/images, and objects without stable ordering can all change the next result.
- “A Path is a Shape.” A Path contains only geometry. It has no business identity, parent-child relationship, permission, or persistence semantics.
Production backpack
Prerequisite contract
The prerequisite is the CanvasHost from Chapter 2. Whenever Bitmap dimensions change, it restores the base DPR Transform and calls the Renderer. The Document is read-only input, and the Renderer does not modify it. The Camera is explicit input. Stable IDs break ties at an equal z value. Time and image resources arrive through explicit parameters.
Formal knowledge
Canvas 2D paths can be built with the current Path (beginPath/moveTo/lineTo/...) or with Path2D. fill() colors the interior, while stroke() draws along the edge. lineCap controls the endpoints of an open segment, and lineJoin controls corners. Both affect visible bounds, so later geometry chapters must agree with them.
clip() limits subsequent drawing to a region and can end only by restoring an earlier state, so wrap it in save/restore. globalCompositeOperation determines how source pixels combine with existing destination pixels, and globalAlpha then adjusts overall transparency. If either leaks, “objects drawn later disappear or fade.” A Gradient is created by the Context and contains color stops. A Pattern repeats an image or Canvas. They are rendering resources and must not be stuffed into the serialized Document. drawImage() can draw an HTMLImageElement, ImageBitmap, and similar sources, while the asset chapter will handle decoding state and CORS. Primitive Text through fillText() works for preview labels but supplies no caret, selection, IME, or rich-text editing.
The Renderer should use fixed Passes. Background fully establishes the base color. Grid draws camera-dependent guides. Shape follows z-order. Overlay draws selection boxes and hints without writing to the Document. Give every Pass and every Shape a withSavedContext boundary so Context State Isolation can be audited. “Pure Renderer” here does not mean mathematically free of side effects—it necessarily writes to the Bitmap. It means that, apart from the target Context, it does not mutate the model or read hidden global state, and equal input produces the same sequence of commands.
Evidence and compatibility
- MDN: Canvas 2D drawing shapes / Path2D
- MDN: Applying styles and colors
- MDN: Compositing and clipping
- MDN: CanvasRenderingContext2D.drawImage
- MDN: save() and restore()
- WHATWG HTML: 2D rendering context
Sources above were checked on 2026-08-29. Check target browsers for roundRect() and some Path2D use cases. When unsupported, build the same outline with moveTo/lineTo/quadraticCurveTo. Visual regression tests must fix the font, DPR, color space, and resource-decoding point in time.
This chapter’s engineering increment
Starting point: the example callback in Chapter 2’s Host calls fillRect directly. Finish line: four fixed Passes; Rectangle, Ellipse, Polyline, Image Placeholder, and Primitive Text Label are all rebuilt from the Document.
canvas-lab/src/lab/ch03/
model.ts
renderer.ts
renderer.test.ts
Complete model and Renderer, with no hidden omission in the key logic:
export type Camera = { x: number; y: number; zoom: number };
export type Shape =
| {
id: string;
type: 'rectangle';
x: number;
y: number;
w: number;
h: number;
fill: string;
z: number;
}
| {
id: string;
type: 'ellipse';
x: number;
y: number;
rx: number;
ry: number;
fill: string;
z: number;
}
| {
id: string;
type: 'polyline';
points: readonly { x: number; y: number }[];
stroke: string;
z: number;
}
| {
id: string;
type: 'image';
x: number;
y: number;
w: number;
h: number;
label: string;
z: number;
}
| { id: string; type: 'text'; x: number; y: number; text: string; color: string; z: number };
export type DocumentModel = {
background: string;
shapes: readonly Shape[];
selectedIds: ReadonlySet<string>;
};
function isolated(ctx: CanvasRenderingContext2D, draw: () => void): void {
ctx.save();
try {
draw();
} finally {
ctx.restore();
}
}
function shapeBounds(shape: Shape): { x: number; y: number; w: number; h: number } {
if (shape.type === 'rectangle' || shape.type === 'image') return shape;
if (shape.type === 'ellipse')
return { x: shape.x - shape.rx, y: shape.y - shape.ry, w: shape.rx * 2, h: shape.ry * 2 };
if (shape.type === 'text')
return { x: shape.x, y: shape.y - 16, w: shape.text.length * 10, h: 20 };
if (shape.points.length === 0) return { x: 0, y: 0, w: 0, h: 0 };
const xs = shape.points.map((p) => p.x),
ys = shape.points.map((p) => p.y);
return {
x: Math.min(...xs),
y: Math.min(...ys),
w: Math.max(...xs) - Math.min(...xs),
h: Math.max(...ys) - Math.min(...ys),
};
}
function drawShape(ctx: CanvasRenderingContext2D, shape: Shape): void {
isolated(ctx, () => {
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
if (shape.type === 'rectangle') {
const path = new Path2D();
path.rect(shape.x, shape.y, shape.w, shape.h);
ctx.fillStyle = shape.fill;
ctx.fill(path);
ctx.strokeStyle = '#0f172a';
ctx.lineWidth = 1;
ctx.stroke(path);
return;
}
if (shape.type === 'ellipse') {
ctx.beginPath();
ctx.ellipse(shape.x, shape.y, shape.rx, shape.ry, 0, 0, Math.PI * 2);
ctx.fillStyle = shape.fill;
ctx.fill();
return;
}
if (shape.type === 'polyline') {
const [first, ...rest] = shape.points;
if (!first) return;
ctx.beginPath();
ctx.moveTo(first.x, first.y);
for (const point of rest) ctx.lineTo(point.x, point.y);
ctx.strokeStyle = shape.stroke;
ctx.lineWidth = 3;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.stroke();
return;
}
if (shape.type === 'image') {
ctx.fillStyle = '#e2e8f0';
ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
ctx.strokeStyle = '#64748b';
ctx.strokeRect(shape.x, shape.y, shape.w, shape.h);
ctx.beginPath();
ctx.moveTo(shape.x, shape.y);
ctx.lineTo(shape.x + shape.w, shape.y + shape.h);
ctx.moveTo(shape.x + shape.w, shape.y);
ctx.lineTo(shape.x, shape.y + shape.h);
ctx.stroke();
ctx.fillStyle = '#334155';
ctx.fillText(shape.label, shape.x + 8, shape.y + 18);
return;
}
ctx.fillStyle = shape.color;
ctx.font = '16px system-ui';
ctx.textBaseline = 'alphabetic';
ctx.fillText(shape.text, shape.x, shape.y);
});
}
export function renderDocument(
model: DocumentModel,
camera: Camera,
ctx: CanvasRenderingContext2D,
viewport: { width: number; height: number; dpr: number },
trace: (event: string) => void = () => undefined,
): void {
const ordered = [...model.shapes].sort((a, b) => a.z - b.z || a.id.localeCompare(b.id));
trace('pass:background');
isolated(ctx, () => {
ctx.setTransform(viewport.dpr, 0, 0, viewport.dpr, 0, 0);
ctx.fillStyle = model.background;
ctx.fillRect(0, 0, viewport.width, viewport.height);
});
trace('pass:grid');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
ctx.strokeStyle = '#e2e8f0';
ctx.lineWidth = 1 / camera.zoom;
const left = Math.floor(camera.x / 20) * 20,
top = Math.floor(camera.y / 20) * 20;
for (let x = left; x < camera.x + viewport.width / camera.zoom; x += 20) {
ctx.beginPath();
ctx.moveTo(x, camera.y);
ctx.lineTo(x, camera.y + viewport.height / camera.zoom);
ctx.stroke();
}
for (let y = top; y < camera.y + viewport.height / camera.zoom; y += 20) {
ctx.beginPath();
ctx.moveTo(camera.x, y);
ctx.lineTo(camera.x + viewport.width / camera.zoom, y);
ctx.stroke();
}
});
trace('pass:shape');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
for (const shape of ordered) {
trace(`shape:${shape.id}`);
drawShape(ctx, shape);
}
});
trace('pass:overlay');
isolated(ctx, () => {
const scale = camera.zoom * viewport.dpr;
ctx.setTransform(scale, 0, 0, scale, -camera.x * scale, -camera.y * scale);
ctx.strokeStyle = '#f97316';
ctx.lineWidth = 2 / camera.zoom;
ctx.setLineDash([6 / camera.zoom, 4 / camera.zoom]);
for (const shape of ordered) {
if (!model.selectedIds.has(shape.id)) continue;
trace(`selection:${shape.id}`);
const b = shapeBounds(shape);
ctx.strokeRect(b.x, b.y, b.w, b.h);
}
});
}
Here viewport.width/height are deliberately defined in CSS Pixels, and every setTransform() includes dpr. Chapter 2’s Host establishes a DPR Transform first, but the Renderer replaces it when it calls setTransform() itself. Writing identity here would make the high-resolution Bitmap fixed in Chapter 2 draw only into its top-left portion. trace is an optional testing probe. It does not enter the Document or alter the drawing result.
A Gradient can be built with createLinearGradient() in the Background Pass, and a Pattern with createPattern(decodedImage, 'repeat'). Real resources use drawImage(). An explicit Resource Registry must supply them. When a resource is not decoded, draw the placeholder implemented above; never let an asynchronous callback secretly patch the old Bitmap.
The test uses state snapshots to prove isolation and repeated command records to prove deterministic ordering:
import { describe, expect, it, vi } from 'vitest';
import { renderDocument, type DocumentModel } from './renderer';
describe('deterministic renderer', () => {
it('restores caller state and emits the same ordered draw trace', () => {
const canvas = document.createElement('canvas');
canvas.width = 640;
canvas.height = 400;
const ctx = canvas.getContext('2d')!;
const model: DocumentModel = {
background: '#fff',
selectedIds: new Set(['b']),
shapes: [
{ id: 'b', type: 'rectangle', x: 20, y: 20, w: 60, h: 40, fill: '#bfdbfe', z: 2 },
{ id: 'a', type: 'text', x: 30, y: 45, text: 'Order', color: '#111827', z: 2 },
],
};
const setTransform = vi.spyOn(ctx, 'setTransform');
ctx.globalAlpha = 0.37;
ctx.setLineDash([9, 3]);
const first: string[] = [];
renderDocument(
model,
{ x: 0, y: 0, zoom: 1 },
ctx,
{ width: 320, height: 200, dpr: 2 },
(event) => first.push(event),
);
expect(first).toEqual([
'pass:background',
'pass:grid',
'pass:shape',
'shape:a',
'shape:b',
'pass:overlay',
'selection:b',
]);
expect(setTransform).toHaveBeenCalledWith(2, 0, 0, 2, 0, 0);
expect(ctx.globalAlpha).toBe(0.37);
expect(ctx.getLineDash()).toEqual([9, 3]);
ctx.clearRect(0, 0, 640, 400);
const second: string[] = [];
renderDocument(
model,
{ x: 0, y: 0, zoom: 1 },
ctx,
{ width: 320, height: 200, dpr: 2 },
(event) => second.push(event),
);
expect(second).toEqual(first);
expect(model.shapes.map((s) => s.id)).toEqual(['b', 'a']);
});
});
Run npx vitest run src/lab/ch03/renderer.test.ts --environment jsdom; expect 1 passed. In the browser visual test, clear Canvas first and then provide the same Document/Camera; screenshots must match within the defined tolerance. Return to the recipe: the Renderer wipes the plate and follows a fixed process every time. No cookie depends on “it was roughly here last time.”
Break it on purpose
Deliberately remove the outer restore() from drawShape, then set a transform, globalAlpha=0.2, a circular clip, and lineJoin='bevel' in the first Shape.
| Injection | Symptom | Evidence | Fix | Regression test | Recovery |
|---|---|---|---|---|---|
| Transform leak | Later objects shift/rotate | Record getTransform() and a screenshot | save + finally restore | Caller transform is equal before and after | Restore the isolator |
| Alpha leak | Every later object fades | globalAlpha after render differs from original | Isolate every Shape explicitly | Alpha sentinel assertion | Remove injection |
| Clip leak | Objects outside the circle disappear | Later Shape commands exist but pixels do not | Keep clip inside paired state | Screenshot proves object after clip remains visible | Clear and redraw fully |
| Line Style leak | Polyline endpoints/corners are wrong | lineCap/Join snapshot | Set inside Shape and restore at the end | Test every permutation of Shape order | Restore default order |
| Random/time enters render | Equal input produces unequal screenshots | Diff two command traces | Make clock/seed an explicit input | Fixed seed replays identically | Remove global read |
For every injection, follow the same cycle: enable one failure switch → record the symptom → save command/state/screenshot evidence → fix the isolation boundary → run unit and visual regression tests → clear the canvas and recover from data alone. Do not hide contamination by “moving the bad Shape to the end.”
Pass with evidence
| Gate | Automated/manual | Evidence |
|---|---|---|
| Bitmap can be cleared completely | Automated | Test explicitly calls clearRect and still rebuilds |
| Document is the only scene input | Automated | Renderer reads no historical pixels/global store |
| Pass order is stable | Automated | Command trace is background→grid→shape→overlay |
| Shape state is isolated | Automated | Transform/Alpha/Clip/Line Style failure regressions |
| Five Shape types are complete | Manual | Screenshot with Rectangle, Ellipse, Polyline, Image Placeholder, and Text Label |
- I can explain Path/Path2D, fill/stroke, and lineCap/lineJoin.
- I put clip, compositing, alpha, gradient, pattern, drawImage, and primitive text in the correct layer.
- Every
save()has an exception-saferestore(). - The Renderer does not modify the Document or read random time, and its ordering is stable.
- After clearing the entire Canvas, I can redraw the same result using only Document, Camera, and resource state.
Explain it to a five-year-old
Without using the words “Renderer,” “Bitmap,” “Context,” “Pass,” or “Deterministic,” answer: Why is “add a little bit to the old plate” unreliable? How do you ensure another chef can make the same smile?
Expand a good jargon-free answer
Things on the old plate may have been removed, covered, or smeared with syrup. Saying only “add a little bit” does not tell us what used to be underneath. We need a complete list and a fixed recipe: wipe the plate, lay down the background, place every food item, and add hints last. Whenever we use a special syrup or mold, we put it away when that step ends. Then, even if the plate is completely empty, another person can rebuild it from the list, and the result does not depend on crumbs left from last time.