Creating a Jigsaw Puzzle Game with Flutter: Complete Tutorial

When I set out to build JigsawEase, my jigsaw puzzle game for mobile, I knew the biggest challenge wouldn’t be game logic — it would be cutting images into interlocking puzzle pieces with those classic knob-and-socket shapes. In this tutorial, I’ll walk you through everything I learned building JigsawEase in Flutter, from generating puzzle pieces to handling drag-and-drop and snapping.

Planning the Game Architecture

Before writing any code, I mapped out the core systems:

  • Piece Generation: Split any image into a grid of interlocking pieces
  • Rendering: Display each piece with its custom clip shape
  • Interaction: Drag pieces around the screen
  • Snapping: Detect when a piece is close to its correct position and snap it into place
  • Photo Import: Let users play with their own photos
  • Persistence: Save and resume progress

Let’s tackle each one.

Designing Puzzle Piece Shapes with CustomClipper

The heart of a jigsaw puzzle is the interlocking shapes. Each piece has four edges — top, right, bottom, left — and each edge can be flat (border pieces), have a knob (protruding tab), or have a socket (indentation). I represent this with a simple model:

enum EdgeType { flat, knob, socket }

class PieceDefinition {
  final int row;
  final int col;
  final EdgeType top;
  final EdgeType right;
  final EdgeType bottom;
  final EdgeType left;

  PieceDefinition({
    required this.row,
    required this.col,
    required this.top,
    required this.right,
    required this.bottom,
    required this.left,
  });
}

When generating the grid, adjacent pieces must have complementary edges — if one piece has a knob on its right side, the piece to its right must have a socket on its left side:

List<PieceDefinition> generatePieceDefinitions(int rows, int cols) {
  final pieces = <PieceDefinition>[];
  // Store horizontal and vertical edge types
  final hEdges = List.generate(
    rows + 1,
    (r) => List.generate(cols, (_) => random.nextBool() ? EdgeType.knob : EdgeType.socket),
  );
  final vEdges = List.generate(
    rows,
    (r) => List.generate(cols + 1, (_) => random.nextBool() ? EdgeType.knob : EdgeType.socket),
  );

  for (int r = 0; r < rows; r++) {
    for (int c = 0; c < cols; c++) {
      pieces.add(PieceDefinition(
        row: r,
        col: c,
        top: r == 0 ? EdgeType.flat : _opposite(hEdges[r][c]),
        bottom: r == rows - 1 ? EdgeType.flat : hEdges[r + 1][c],
        left: c == 0 ? EdgeType.flat : _opposite(vEdges[r][c]),
        right: c == cols - 1 ? EdgeType.flat : vEdges[r][c + 1],
      ));
    }
  }
  return pieces;
}

EdgeType _opposite(EdgeType type) {
  if (type == EdgeType.knob) return EdgeType.socket;
  if (type == EdgeType.socket) return EdgeType.knob;
  return EdgeType.flat;
}

Now for the actual clipping shape. I use a CustomClipper<Path> that draws the piece outline with bezier curves for the knobs and sockets:

class PieceClipper extends CustomClipper<Path> {
  final PieceDefinition definition;
  final double pieceWidth;
  final double pieceHeight;

  PieceClipper(this.definition, this.pieceWidth, this.pieceHeight);

  @override
  Path getClip(Size size) {
    final path = Path();
    final knobSize = pieceWidth * 0.2;

    // Start at top-left
    path.moveTo(0, 0);

    // Top edge
    _drawEdge(path, definition.top, knobSize,
        Offset(0, 0), Offset(pieceWidth, 0), isHorizontal: true);

    // Right edge
    _drawEdge(path, definition.right, knobSize,
        Offset(pieceWidth, 0), Offset(pieceWidth, pieceHeight), isHorizontal: false);

    // Bottom edge (reversed direction)
    _drawEdge(path, definition.bottom, knobSize,
        Offset(pieceWidth, pieceHeight), Offset(0, pieceHeight), isHorizontal: true);

    // Left edge (reversed direction)
    _drawEdge(path, definition.left, knobSize,
        Offset(0, pieceHeight), Offset(0, 0), isHorizontal: false);

    path.close();
    return path;
  }

  @override
  bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}

The _drawEdge method uses cubic bezier curves to draw smooth knob and socket shapes. A knob curves outward from the edge, while a socket curves inward. The key is making the curves smooth enough that pieces look natural.

Splitting Images into Puzzle Pieces

With the clipping shapes defined, I split the source image into individual piece images. Each piece is a ClipPath widget wrapping a positioned portion of the original image:

Widget buildPiece(PieceDefinition def, ui.Image image, double pieceW, double pieceH) {
  final margin = pieceW * 0.2; // Extra space for knobs

  return ClipPath(
    clipper: PieceClipper(def, pieceW, pieceH),
    child: CustomPaint(
      painter: PieceImagePainter(
        image: image,
        srcRect: Rect.fromLTWH(
          def.col * pieceW - margin,
          def.row * pieceH - margin,
          pieceW + margin * 2,
          pieceH + margin * 2,
        ),
      ),
      size: Size(pieceW + margin * 2, pieceH + margin * 2),
    ),
  );
}

The extra margin around each piece ensures that knobs extending beyond the base rectangle are visible and not clipped.

Drag-and-Drop with GestureDetector

Each puzzle piece needs to be draggable. I wrap each piece in a Positioned widget inside a Stack, and use GestureDetector for drag handling:

class DraggablePiece extends StatefulWidget {
  final PieceDefinition definition;
  final Offset correctPosition;
  final Offset initialPosition;

  // ...
}

class _DraggablePieceState extends State<DraggablePiece> {
  late Offset position;
  bool isPlaced = false;

  @override
  void initState() {
    super.initState();
    position = widget.initialPosition;
  }

  @override
  Widget build(BuildContext context) {
    if (isPlaced) {
      return Positioned(
        left: widget.correctPosition.dx,
        top: widget.correctPosition.dy,
        child: buildPiece(widget.definition),
      );
    }

    return Positioned(
      left: position.dx,
      top: position.dy,
      child: GestureDetector(
        onPanUpdate: (details) {
          setState(() {
            position += details.delta;
          });
        },
        onPanEnd: (_) => _checkSnap(),
        child: buildPiece(widget.definition),
      ),
    );
  }
}

Piece Snapping Logic

When the user releases a piece, I check if it’s close enough to its correct position. If the distance is within a threshold, the piece snaps into place:

void _checkSnap() {
  final distance = (position - widget.correctPosition).distance;
  final snapThreshold = widget.pieceWidth * 0.3;

  if (distance < snapThreshold) {
    setState(() {
      isPlaced = true;
    });
    // Haptic feedback for satisfying snap
    HapticFeedback.mediumImpact();
    // Notify parent that a piece was placed
    widget.onPiecePlaced(widget.definition);
  }
}

The snap threshold is proportional to the piece size — about 30% of the piece width works well. This is forgiving enough to feel easy but precise enough that it doesn’t snap from too far away.

I also added a subtle scale animation when a piece snaps into place. The piece briefly enlarges and shrinks back, giving satisfying visual feedback:

AnimatedScale(
  scale: _justSnapped ? 1.05 : 1.0,
  duration: const Duration(milliseconds: 150),
  child: buildPiece(widget.definition),
)

One of JigsawEase’s most popular features is playing with your own photos. I use the image_picker package to let users choose an image:

Future<void> pickImage(ImageSource source) async {
  final picker = ImagePicker();
  final pickedFile = await picker.pickImage(
    source: source,
    maxWidth: 1920,
    maxHeight: 1920,
  );

  if (pickedFile != null) {
    final bytes = await pickedFile.readAsBytes();
    final codec = await ui.instantiateImageCodec(bytes);
    final frame = await codec.getNextFrame();
    setState(() {
      puzzleImage = frame.image;
    });
    _startNewPuzzle();
  }
}

I limit the max dimensions to 1920 pixels to keep memory usage reasonable. The image is then cropped to a square or the grid’s aspect ratio before being split into pieces.

Multiple Difficulty Levels

JigsawEase offers different grid sizes for varying difficulty:

Difficulty Grid Pieces
Easy 3×3 9
Medium 4×5 20
Hard 6×8 48
Expert 8×10 80

The piece generation and snapping logic is grid-size agnostic, so adding new difficulty levels is just a matter of changing two numbers. The snap threshold scales with piece size automatically.

Progress Saving with SharedPreferences

Nobody wants to lose progress on a 80-piece puzzle. I serialize the game state — which pieces are placed, the positions of unplaced pieces, and the source image reference — to SharedPreferences:

Future<void> saveProgress() async {
  final prefs = await SharedPreferences.getInstance();
  final state = {
    'imageId': currentImageId,
    'rows': gridRows,
    'cols': gridCols,
    'pieces': pieces.map((p) => {
      return {
        'row': p.definition.row,
        'col': p.definition.col,
        'placed': p.isPlaced,
        'x': p.position.dx,
        'y': p.position.dy,
      };
    }).toList(),
  };
  await prefs.setString('puzzle_progress', jsonEncode(state));
}

Future<void> loadProgress() async {
  final prefs = await SharedPreferences.getInstance();
  final json = prefs.getString('puzzle_progress');
  if (json != null) {
    final state = jsonDecode(json) as Map<String, dynamic>;
    // Restore puzzle state from saved data
    _restoreFromState(state);
  }
}

I auto-save whenever a piece is placed, so progress is never lost even if the app is killed.

Completion Detection and Celebration

When the last piece snaps into place, it’s time to celebrate. I check if all pieces are placed after each snap:

void onPiecePlaced(PieceDefinition def) {
  placedCount++;
  saveProgress();

  if (placedCount == totalPieces) {
    _showCompletionAnimation();
  }
}

The completion animation removes the piece borders briefly, revealing the full uncut image, then triggers a confetti animation using a particle system. It’s a small touch, but players love it.

Performance Considerations

A few things I learned about performance:

  1. Limit piece count wisely. Beyond 100 pieces on mobile, the number of GestureDetector widgets starts to impact frame rate. I cap at 80 pieces for smooth performance.

  2. Use RepaintBoundary. Wrapping each piece in a RepaintBoundary prevents the entire stack from repainting when one piece moves.

  3. Z-ordering matters. The piece being dragged should always be on top. I manage a z-index list and move the active piece to the front of the Stack’s children.

  4. Pre-compute clip paths. The bezier curves for each piece shape are calculated once during initialization, not on every frame.

Wrapping Up

Building JigsawEase was a deep dive into Flutter’s rendering and gesture systems. The CustomClipper API is incredibly flexible — once you understand bezier curves, you can create any interlocking shape you want. The drag-and-drop system is straightforward, and the snapping logic is just basic distance math.

If you want to build your own puzzle game, start with a simple 3×3 grid with straight-cut pieces (no knobs). Get the dragging and snapping working first, then add the interlocking shapes. Complexity is best added incrementally.

JigsawEase is available on the App Store and Google Play. Try it out and let me know what puzzles you create with your own photos!