DEV Community

Cliff
Cliff

Posted on

How to Build a Browser-Based Jigsaw Puzzle Game with JavaScript and HTML5 Canvas

Building a jigsaw puzzle game for the browser is an interesting combination of geometry, graphics programming, and interactive game development.

Unlike a traditional grid-based puzzle, a jigsaw puzzle requires irregular piece shapes, matching edges, image clipping, and precise drag-and-drop interactions. Players should be able to move individual pieces, connect matching pieces, and gradually reconstruct the original image.

In this tutorial, we'll explore how to build a browser-based jigsaw puzzle game using JavaScript and HTML5 Canvas, starting with the basic game architecture and moving through piece generation, rendering, interaction, and performance optimization.

The goal is to create a fully playable game that runs directly in a web browser without requiring downloads or additional software.

  1. Planning the Game Architecture

Before implementing the puzzle mechanics, it's useful to break the game into smaller components.

A browser-based jigsaw puzzle game typically needs several systems:

Component

Responsibility

Game Manager

Controls the overall game state

Puzzle Generator

Divides the original image into pieces

Shape Generator

Creates realistic jigsaw piece boundaries

Canvas Renderer

Draws pieces and the game board

Input Manager

Handles mouse and touch interactions

Snap Manager

Detects matching pieces and connections

Progress Tracker

Tracks completed pieces and game progress

Keeping these systems separate makes the game easier to maintain and extend.

For example, changing the puzzle difficulty from 16 pieces to 100 pieces should not require rewriting the rendering or interaction logic.

A basic game state might look like this:

`const gameState = {
status: "playing",

rows: 4,
cols: 4,

pieces: [],

selectedPiece: null,

placedCount: 0,

startTime: Date.now()
};`

Possible game states include:

loading: Preparing the puzzle image and geometry.

playing: The player is assembling the puzzle.

paused: Gameplay is temporarily suspended.

completed: All pieces have been correctly assembled.

This simple structure provides a foundation for implementing the rest of the game.

  1. Creating the Puzzle Grid

The first step is dividing the original image into a rectangular grid.

Suppose the source image has a width of 1200 pixels and a height of 800 pixels.

For a 4 × 4 puzzle:

const rows = 4;
const cols = 4;

const pieceWidth = image.width / cols;
const pieceHeight = image.height / rows;

Each piece has a logical position in the completed image.

We can calculate its original coordinates using its row and column:

function getPiecePosition(row, col, width, height) {
return {
x: col * width,
y: row * height
};
}

However, these rectangular regions are only the starting point.

A realistic jigsaw puzzle requires each piece to have a unique outline, with tabs and blanks that fit neighboring pieces.

This brings us to the most important part of the geometry system.

  1. Generating Matching Jigsaw Piece Edges

A common mistake when implementing jigsaw puzzles is generating every piece independently.

This can lead to small differences between neighboring edges, creating visible gaps or overlaps.

A better approach is to generate shared boundaries first.

Each internal boundary is created once and reused by the two adjacent pieces.

For a grid of rows and columns, we need two sets of boundaries:

Horizontal boundaries between rows.

Vertical boundaries between columns.

Each boundary contains a geometric description and a polarity.

For example:

Positive polarity: A protruding tab.

Negative polarity: A recessed blank.

A simplified edge map can be generated as follows:

function randomPolarity() {
  return Math.random() > 0.5 ? 1 : -1;
}

function createEdgeMap(rows, cols) {
  const horizontal = [];
  const vertical = [];

  for (let r = 1; r < rows; r++) {
    horizontal[r] = [];

    for (let c = 0; c < cols; c++) {
      horizontal[r][c] = randomPolarity();
    }
  }

  for (let r = 0; r < rows; r++) {
    vertical[r] = [];

    for (let c = 1; c < cols; c++) {
      vertical[r][c] = randomPolarity();
    }
  }

  return { horizontal, vertical };
}
Enter fullscreen mode Exit fullscreen mode

In a complete implementation, each entry should store the full edge geometry rather than just its polarity.

The neighboring pieces then reuse the same geometry in opposite directions.

This ensures that their boundaries match exactly.

Outer puzzle boundaries are treated differently: they remain straight rather than receiving tabs or blanks.

  1. Creating Realistic Piece Shapes with Bézier Curves

Jigsaw puzzle pieces are recognizable because of their characteristic tabs and recessed edges.

We can approximate these shapes using cubic Bézier curves.

A cubic Bézier curve is defined by four control points:

P0: Starting point.

P1: First control point.

P2: Second control point.

P3: Ending point.

Its mathematical representation is:

B(t) = (1-t)³P0
+ 3(1-t)²tP1
+ 3(1-t)t²P2
+ t³P3

where t ranges from 0 to 1.

By adjusting the control points, we can create the rounded head and narrow neck of a traditional jigsaw tab.

For example, Canvas provides a convenient method:

ctx.bezierCurveTo(
controlX1,
controlY1,
controlX2,
controlY2,
endX,
endY
);

A simplified edge renderer might look like this:

function drawTab(ctx, x, y, width, depth, direction) {
  const w = width;
  const d = depth * direction;

  ctx.lineTo(x + w * 0.25, y);

  ctx.bezierCurveTo(
    x + w * 0.25, y - d * 0.15,
    x + w * 0.28, y - d * 0.55,
    x + w * 0.40, y - d * 0.55
  );

  ctx.bezierCurveTo(
    x + w * 0.45, y - d * 0.95,
    x + w * 0.55, y - d * 0.95,
    x + w * 0.60, y - d * 0.55
  );

  ctx.bezierCurveTo(
    x + w * 0.72, y - d * 0.55,
    x + w * 0.75, y - d * 0.15,
    x + w * 0.75, y
  );

  ctx.lineTo(x + w, y);
}
Enter fullscreen mode Exit fullscreen mode

The direction determines whether the tab protrudes or curves inward.

For a production-quality game, the geometry should also account for:

Consistent endpoints and tangent directions.

Variable tab widths and depths.

Different shapes for horizontal and vertical edges.

Reversed traversal when rendering neighboring pieces.

Shared control points to prevent mismatched boundaries.

The objective is to generate varied-looking pieces while maintaining mathematically consistent connections.

  1. Constructing the Complete Puzzle Piece

Once all four edges are available, we can construct the outline of each piece.

A convenient strategy is to trace the shape clockwise:

Start at the top-left corner.

Draw the top edge from left to right.

Draw the right edge from top to bottom.

Draw the bottom edge from right to left.

Draw the left edge from bottom to top.

Close the path.

A simplified structure looks like this:

function createPiecePath(ctx, piece) {
  const { x, y, edges } = piece;

  ctx.beginPath();
  ctx.moveTo(x, y);

  drawEdge(ctx, edges.top);
  drawEdge(ctx, edges.right);
  drawEdge(ctx, edges.bottom, true);
  drawEdge(ctx, edges.left, true);

  ctx.closePath();
}
Enter fullscreen mode Exit fullscreen mode

Here, drawEdge() is a reusable function that renders the corresponding edge geometry.

For a clean implementation, it is helpful to define each edge in local coordinates and apply transformations when placing it on different sides of the piece.

This avoids duplicating the same Bézier geometry for horizontal and vertical boundaries.

  1. Rendering Puzzle Pieces with HTML5 Canvas

With the geometry generated, we can start rendering the actual puzzle.

The key technique is Canvas clipping.

Instead of creating a separate cropped image for every piece, we draw the original image through each piece's irregular clipping path.

This preserves image alignment across the entire puzzle.

The basic process is:

Save the current Canvas state.

Translate to the piece's position.

Define its clipping path.

Draw the original image.

Restore the Canvas state.

For example:

function renderPiece(ctx, image, piece) {
  ctx.save();

  ctx.translate(piece.currentX, piece.currentY);

  const path = createLocalPiecePath(piece);

  ctx.clip(path);

  ctx.drawImage(
    image,
    -piece.sourceX,
    -piece.sourceY
  );

  ctx.restore();
}
Enter fullscreen mode Exit fullscreen mode

The important detail is that every piece uses the same original image coordinate system.

This means that when all pieces are placed correctly, the image lines up seamlessly.

We can also draw a border around each piece:

ctx.save();

ctx.translate(piece.currentX, piece.currentY);

const path = createLocalPiecePath(piece);

ctx.strokeStyle = "rgba(0, 0, 0, 0.25)";
ctx.lineWidth = 1.5;

ctx.stroke(path);

ctx.restore();

Enter fullscreen mode Exit fullscreen mode

Additional visual effects such as shadows, hover highlights, and selection outlines can make the game feel much more responsive.

  1. Implementing Drag-and-Drop Interaction

A jigsaw puzzle would not be playable without intuitive piece movement.

Each piece needs to track two different positions:

Its original position in the completed puzzle.

Its current position on the game board.

A simple piece object might look like this:

`const piece = {
id: 0,

correctX: 120,
correctY: 80,

currentX: 350,
currentY: 220,

isDragging: false,
isPlaced: false
};`

When the player selects a piece, we record the pointer offset:

function startDrag(piece, pointerX, pointerY) {
  piece.isDragging = true;

  piece.dragOffsetX = pointerX - piece.currentX;
  piece.dragOffsetY = pointerY - piece.currentY;
}

While dragging:

function movePiece(piece, pointerX, pointerY) {
  if (!piece.isDragging) return;

  piece.currentX = pointerX - piece.dragOffsetX;
  piece.currentY = pointerY - piece.dragOffsetY;
}

Enter fullscreen mode Exit fullscreen mode

When the pointer is released, the game can check whether the piece is close enough to its correct position.

For browser games, the Pointer Events API is particularly useful because it supports mouse, touch, and pen input through a unified event model.

For example:

canvas.addEventListener("pointerdown", handlePointerDown);
canvas.addEventListener("pointermove", handlePointerMove);
canvas.addEventListener("pointerup", handlePointerUp);

canvas.addEventListener("pointercancel", handlePointerUp);

For mobile browsers, setting an appropriate touch-action CSS property on the game interaction surface also helps prevent unwanted browser gestures while dragging.

  1. Detecting Correct Placement and Snapping Pieces

A simple snapping system compares the current position of a piece with its original position.

We can use Euclidean distance:

function checkSnap(piece, threshold = 20) {
  const dx = piece.currentX - piece.correctX;
  const dy = piece.currentY - piece.correctY;

  const distance = Math.hypot(dx, dy);

  if (distance < threshold) {
    piece.currentX = piece.correctX;
    piece.currentY = piece.correctY;

    piece.isPlaced = true;

    return true;
  }

  return false;
}
Enter fullscreen mode Exit fullscreen mode

This works well for a basic puzzle where pieces are placed directly into their final positions.

However, more advanced games can support connections between neighboring pieces before they reach the correct location.

For example, when two pieces are close enough and their corresponding edges match, they can snap together and form a larger group.

This introduces the concept of connected piece clusters.

A cluster can be represented as a collection of pieces that share a common movement offset.

When the player drags one piece in a connected cluster, all the other pieces move with it.

This creates a more natural puzzle-solving experience, especially for larger puzzles.

  1. Adding Difficulty Levels and Game Progress

A complete browser-based jigsaw puzzle game should offer more than just a fixed grid.

Different piece counts can provide different difficulty levels.

For example:

Difficulty

Grid

Total Pieces

Easy

3 × 3

9

Medium

5 × 5

25

Hard

8 × 8

64

Expert

10 × 10

100

The actual difficulty also depends on the image itself, the shape variation, and whether players can use a reference image.

A progress tracker can be implemented with a simple counter:

function updateProgress(gameState) {
  const total = gameState.rows * gameState.cols;

  const progress = gameState.placedCount / total;

  if (progress >= 1) {
    gameState.status = "completed";
  }

  return progress;
}

Enter fullscreen mode Exit fullscreen mode

Other useful gameplay features include:

A timer to track completion time.

A preview of the completed image.

A shuffle function.

A restart option.

A completion animation.

A responsive puzzle board for smaller screens.

These features turn the underlying rendering engine into a more complete game experience.

  1. Optimizing Performance for Desktop and Mobile Browsers

Performance becomes increasingly important as the number of pieces grows.

A 3 × 3 puzzle is relatively easy to render. A 100-piece puzzle requires more careful management.

Several techniques can help.

Separate rendering layers

Instead of redrawing everything unnecessarily, divide the game into layers:

Background and board.

Static completed pieces.

Loose puzzle pieces.

Active dragging and interaction effects.

Use offscreen rendering

Static piece images can be rendered into offscreen canvases and reused.

This reduces repeated clipping and drawing operations.

Optimize hit testing

Checking every pixel of every piece is expensive.

Start with bounding-box checks to eliminate pieces that are clearly not under the pointer. More precise path-based checks can then be performed on the remaining candidates.

Avoid unnecessary redraws

A requestAnimationFrame-based rendering loop can be used to update the game smoothly, but static scenes do not need to be redrawn continuously.

Consider device pixel ratio

High-density mobile screens require careful Canvas scaling to keep puzzle edges sharp without creating excessively large rendering surfaces.

The best optimization strategy depends on the puzzle size, device capabilities, and rendering architecture.

  1. Putting Everything Together

At this point, we have the major building blocks of a browser-based jigsaw puzzle game:

Grid generation determines the number and dimensions of pieces.

Edge generation creates matching boundaries.

Bézier curves define realistic tabs and blanks.

Canvas clipping maps the original image onto each piece.

Pointer events enable interactive dragging.

Snap detection handles correct placement.

Game state management tracks progress and completion.

The complete gameplay flow can be summarized as:

Load Puzzle Image
|
v
Generate Puzzle Grid
|
v
Create Shared Edge Geometry
|
v
Generate Individual Pieces
|
v
Shuffle Pieces on the Board
|
v
Player Drags and Connects Pieces
|
v
Check Placement and Connections
|
v
Update Game Progress
|
v
Complete the Puzzle

The most important architectural decision is keeping geometry, rendering, interaction, and game state independent.

This makes it easier to add new features without introducing unnecessary complexity into the core puzzle engine.

  1. Try a Browser-Based Jigsaw Puzzle Game

The techniques discussed in this tutorial are the foundation of an interactive jigsaw puzzle experience.

If you want to explore how a browser-based jigsaw puzzle game works in practice, you can try Jigsaw Puzzle Spot.

It offers free online jigsaw puzzles that can be played directly in the browser, providing a practical example of image-based puzzle gameplay without requiring a separate application or installation.

Conclusion

Building a browser-based jigsaw puzzle game with JavaScript and HTML5 Canvas involves several interesting engineering challenges.

The most important ones are generating matching piece geometry, rendering irregular shapes, maintaining image alignment, and implementing intuitive drag-and-drop interactions.

By separating these systems into reusable components, it is possible to build a flexible puzzle engine that supports different image sizes, difficulty levels, and gameplay mechanics.

What starts as a simple image-splitting exercise can become a surprisingly rich browser game development project.

And the best part is that the entire experience can run directly in a modern web browser.

Top comments (0)