A jigsaw puzzle generator for Canvas2D and WebGPU.
This library is still unstable.
import {
createPuzzleLayout,
generatePuzzle,
generatePuzzleCanvas2D,
generatePuzzleWebGPU,
generatePuzzleWebGPUTextures,
isWebGPUSupported,
revokePuzzleImageSource,
} from "jigsaw-canvas";All renderers use the same layout generator:
const layout = createPuzzleLayout({
rows,
columns,
imageWidth,
imageHeight,
random,
});createPuzzleLayout returns deterministic, DOM-free puzzle geometry. It validates that rows and columns are positive integers and that image dimensions are positive finite numbers. Boundary edges are straight, adjacent pieces receive complementary connector edges, and each piece includes renderer-neutral outline geometry so Canvas2D and WebGPU draw the same piece boundary.
type PuzzleImage = CanvasImageSource & {
width: number;
height: number;
};
type GeneratePuzzleOptions = {
random?: () => number;
connectorStyle?: ConnectorStyle | ConnectorStyle[];
imageType?: string;
imageQuality?: number;
imageOutput?: "blob-url" | "data-url";
onProgress?: (progress: PuzzleGenerationProgress) => void;
};
type PuzzleGenerationProgress = {
completedPieces: number;
elapsedMs: number;
pieceIndex: number;
progress: number;
totalPieces: number;
};
type ConnectorStyle = "classic" | "round" | "wave" | "angular" | "dovetail";random controls connector polarity and any per-edge style choices, which is useful for reproducible puzzles. connectorStyle defaults to "classic" for the current tab/slot design. Passing an array chooses one style per shared interior edge. imageType and imageQuality are passed to browser image encoding. imageOutput defaults to "blob-url" because blob URLs avoid base64-heavy toDataURL() output.
onProgress is called once per generated piece. progress is a 0..1 ratio, elapsedMs is measured from the start of the generation call, and pieceIndex is zero-based. Canvas2D and WebGPU image-source renderers report after each piece has been encoded. generatePuzzleWebGPUTextures reports after each GPU texture has been rendered.
The generator now separates topology, connector assignment, and outline generation. The first topology is still the rectangular row/column grid, but renderers consume explicit piece outlines so future irregular topologies can reuse the same Canvas2D and WebGPU paths.
type PieceEdge = {
polarity: "straight" | "out" | "in";
style?: ConnectorStyle;
sharedEdgeId?: string;
};
type BoundarySegment =
| { kind: "line"; start: Point; end: Point }
| { kind: "cubic"; start: Point; cp1: Point; cp2: Point; end: Point };
type PuzzlePieceLayout = {
index: number;
grid?: { row: number; col: number };
edges: Record<"TOP" | "RIGHT" | "BOTTOM" | "LEFT", PieceEdge>;
outline: BoundarySegment[];
sourceBounds: { x: number; y: number; width: number; height: number };
margins: PieceMargins;
containerWidth: number;
containerHeight: number;
width: number;
height: number;
};const pieces = await generatePuzzle(image, rows, columns, options);
const samePieces = await generatePuzzleCanvas2D(image, rows, columns, options);generatePuzzle is an alias for generatePuzzleCanvas2D. Canvas2D clips each piece with a Path2D, draws the source image under that clipped path, encodes each piece image, and returns:
type RenderedPuzzlePiece = {
imageSrc: string;
edges: Record<"TOP" | "RIGHT" | "BOTTOM" | "LEFT", PieceEdge>;
outline: BoundarySegment[];
sourceBounds: { x: number; y: number; width: number; height: number };
widthPercentage: number;
heightPercentage: number;
} & PuzzlePieceLayout;Use piece.imageSrc as an <img src> value. If the default blob URL output is used, release sources when finished:
for (const piece of pieces) {
revokePuzzleImageSource(piece.imageSrc);
}Use { imageOutput: "data-url" } only when you specifically need inline data URLs.
if (isWebGPUSupported()) {
const pieces = await generatePuzzleWebGPU(image, rows, columns, options);
}generatePuzzleWebGPU renders each piece into a WebGPU texture, masks the sampled outline in WGSL, reads pixels back, encodes the result through the same image-output pipeline as Canvas2D, and returns RenderedPuzzlePiece[].
This renderer preserves the Canvas2D return shape, so it is easy to drop into DOM/image based demos. Because it reads pixels back and encodes image sources, it is not the fastest path for interactive GPU animation.
if (isWebGPUSupported()) {
const texturePieces = await generatePuzzleWebGPUTextures(
image,
rows,
columns,
{ random }
);
}generatePuzzleWebGPUTextures renders each piece into a GPU-resident texture and returns:
type WebGPUPuzzlePiece = {
texture: GPUTexture;
textureView: GPUTextureView;
widthPercentage: number;
heightPercentage: number;
destroy(): void;
} & PuzzlePieceLayout;Use this API for a custom WebGPU render loop where pieces stay on the GPU for transforms, dragging, animation, or composition. Call piece.destroy() when a texture piece is no longer needed.
The library also exposes pure geometry helpers for outline hit testing and physical-fit snapping. These helpers do not require DOM, Canvas2D, or WebGPU.
const hit = hitTestPiece(piece, { x, y });
const snap = findPhysicalSnapCandidate({
moving,
targets,
board,
tolerance: 24,
});hitTestPiece samples piece.outline, checks bounds first, then runs point-in-polygon testing. findPhysicalSnapCandidate looks for physically compatible edges by polarity and connector style, not by sharedEdgeId, so a wrong piece can still snap when it physically fits. Use canPlacePiece to reject placements outside the board or overlapping other pieces.
- Use
generatePuzzle/generatePuzzleCanvas2Dfor broad browser support and normal DOM image pieces. - Use
generatePuzzleWebGPUwhen you want the same image-source output but prefer WebGPU generation where supported. - Use
generatePuzzleWebGPUTextureswhen you are building a WebGPU scene and want to avoid readback and image encoding.
Install dependencies:
yarn installBuild the package and demo outputs:
yarn buildRun all checks:
yarn typecheck
yarn lint
yarn testThe package source lives in src/. Demo source files stay as TypeScript. Package output is written to dist/, and bundled demo output is written to demo-dist/.
yarn installyarn build:libyarn dev:demo
Then open the local URL printed by Vite.
To build the static demo:
yarn build:demoInstall and build from the repository root:
yarn install
yarn buildStart the client dev server from the repository root:
yarn dev:multiplayer-clientOpen http://localhost:8080 in a browser.
Start the backend server in another terminal:
yarn build:multiplayer-server
yarn start:multiplayer-serverOpen the same client URL in another browser tab. Moving pieces in one tab should sync to the other.
Before releasing, run:
yarn typecheck
yarn lint
yarn test
yarn buildVerify the npm package contents:
npm pack --dry-runThe published package is limited by package.json files and should contain only:
dist/README.mdLICENSEpackage.json
Demos, tests, TypeScript source, and development config are intentionally excluded from the release package.
Create a release:
yarn releaseprepack runs yarn build:lib, so dist/ is regenerated before packing or publishing.