JEPA4Japan · tutorials

Chapter 3: Turn Drawing into a Replayable Recipe

2,760 words 13 min read #Canvas#Frontend Engineering#Infinite Canvas#ELI5

Replace incremental painting with a deterministic, multi-pass renderer that can rebuild the complete view from the document.

Course progress Course outline 18 of 18 lessons available

Part I: Choose the Surface Before You Draw—Product, Pixels, and Coordinates

  1. 01 Chapter 1: Do Not Draw Yet—Canvas Is Not a Product Architecture available now
  2. 02 Chapter 2: A Sheet of Pixels That Forgets available now
  3. 03 Chapter 3: Turn Drawing into a Replayable Recipe Current lesson
  4. 04 Chapter 4: Four Maps and a Camera available now

Part II: Give the Pixel World a Brain—Model, Scheduling, Input, and Tools

  1. 05 Chapter 5: Give the Pixel World a Registry available now
  2. 06 Chapter 6: Redraw Only When the Light Turns On—Render Scheduling and the React Boundary available now
  3. 07 Chapter 7: Mouse, Touch, and Pen Speak One Language available now
  4. 08 Chapter 8: Find the Big Box Before Inspecting the Edge available now
  5. 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

  1. 10 Chapter 10: Make the Editor Feel Right available now
  2. 11 Chapter 11: Drawn Text Is Not Editable Text available now
  3. 12 Chapter 12: Borrowed Images Cannot Be Packed Without Rules available now
  4. 13 Chapter 13: Time Machines and Old Boxes available now
  5. 14 Chapter 14: Looking Correct Is Not Being Correct available now

Part IV: Master-Level Decisions—Performance, Workers, GPU, SDKs, Collaboration, and AI

  1. 15 Chapter 15: Do Not Search Ten Thousand Children One by One available now
  2. 16 Chapter 16: Keep the Front Desk Out of the Kitchen—Worker and GPU Upgrades available now
  3. 17 Chapter 17: Build the Car or Buy a Proven Chassis? available now
  4. 18 Chapter 18: People and AI Edit the Same Ledger available now

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?

  1. Read the whole recipeObjects and camera
  2. Wipe the plate cleanDo not rely on old colors
  3. Arrange in layersBackground → shapes → hints
  4. Compare resultsThe same every time
Reliable drawing is a replayable recipe, not a patch applied to an old plate.

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 conceptCanvas engineeringPurpose
Ingredient listDocument ModelStores facts
View of the arrangementCameraDetermines how the world is projected
RecipePure RendererThe same input produces the same drawing commands
Wiping the plateclear/background passRemoves the old Bitmap
Mold outlinePath / Path2DDescribes a geometric boundary
Syrup and outlinesFill / StrokeColors the inside or edge of a path
Putting on and removing glovessave() / restore()Isolates Transform, Alpha, Clip, and styles
Chefs with separate jobsRender PassOrders 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 with restore().” It does not. An exception or early return leaks state, so use try/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

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.

InjectionSymptomEvidenceFixRegression testRecovery
Transform leakLater objects shift/rotateRecord getTransform() and a screenshotsave + finally restoreCaller transform is equal before and afterRestore the isolator
Alpha leakEvery later object fadesglobalAlpha after render differs from originalIsolate every Shape explicitlyAlpha sentinel assertionRemove injection
Clip leakObjects outside the circle disappearLater Shape commands exist but pixels do notKeep clip inside paired stateScreenshot proves object after clip remains visibleClear and redraw fully
Line Style leakPolyline endpoints/corners are wronglineCap/Join snapshotSet inside Shape and restore at the endTest every permutation of Shape orderRestore default order
Random/time enters renderEqual input produces unequal screenshotsDiff two command tracesMake clock/seed an explicit inputFixed seed replays identicallyRemove 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

GateAutomated/manualEvidence
Bitmap can be cleared completelyAutomatedTest explicitly calls clearRect and still rebuilds
Document is the only scene inputAutomatedRenderer reads no historical pixels/global store
Pass order is stableAutomatedCommand trace is background→grid→shape→overlay
Shape state is isolatedAutomatedTransform/Alpha/Clip/Line Style failure regressions
Five Shape types are completeManualScreenshot 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-safe restore().
  • 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.