Making a Jigsaw Puzzle in Godot

dynamic object splitting is simple!

Note from January 14th, 2025: These are not smooth jigsaw pieces! I wanted this specifically so I could physically move them around. This will probably not help you!

This problem confused me for a bit, but luckily, godot makes splitting a puzzle piece pretty simple.

Note: for simplicity, I’ll be using rectangular pegs, but the same principles apply to any shape. If you want to make a PR to the example project demonstrating a different shape, I’d be happy to merge it.

You can find the entire project here (LeoDog896/godot-jigsaw).

I’ll be using this silly ferret image:

ferret yawning on a blue blanket

layout

We’ll have two scenes for the library, and one for demonstration:

Godot’s Polygon2D has a texture property that is usually used for UV mapping, but we can use it to split the image into pieces.

representing hinges

GDScript has the concept of Enums, which are basically constants. We’ll use them to represent the different states of a hinge:

enum HingeState {
	EXTENDED = 0,
	CONTRACTED = 1,
	NONE = 2
}

generating the piece orientation

We’ll attach a script to the Puzzle.tscn scene, allowing it to take in a Texture and the number of rows and columns to split the image into multiple pieces.

We can pass down the context info to the puzzle pieces, so that the puzzle pieces themselves can be created independently.

extends Node2D

export (Texture) var texture

export (int) var rows = 0
export (int) var cols = 0

var puzzle_piece := preload("res://puzzle/PuzzlePiece.tscn")

func _ready():
	for n in rows * cols:
		var piece := puzzle_piece.instance()

		# we need to pass down context info to the puzzle piece
		# from the parent to keep it isolated
		piece.rows = rows
		piece.cols = cols

		piece.row = n / cols
		piece.col = n % cols

		piece.texture = texture

		var image_size := Vector2(texture.get_width(), texture.get_height())

		piece.piece_scale = image_size / Vector2(cols, rows) * scale

		piece.position = (piece.piece_scale * 2 * Vector2(piece.col, piece.row)) - (image_size * scale)

		add_child(piece)

We also need to track the neighbors of the puzzle pieces so we can generate the hinges for the piece, so we can make a pieces array and add it to the scene at the end:

extends Node2D

export (Texture) var texture

export (int) var rows = 0
export (int) var cols = 0

var puzzle_piece := preload("res://puzzle/PuzzlePiece.tscn")

func _reverse_hinge(hinge: int) -> int:
	return 0 if hinge == 1 else 1

func _ready():
	var pieces: Array = []

	for n in rows * cols:
		var piece := puzzle_piece.instance()

		# we need to pass down context info to the puzzle piece
		# from the parent to keep it isolated
		piece.rows = rows
		piece.cols = cols

		piece.row = n / cols
		piece.col = n % cols

		piece.texture = texture

		var neighbors := {
			top = null if piece.row == 0 else pieces[n - cols],
			left = null if piece.col == 0 else pieces[n - 1],
		}

		var image_size := Vector2(texture.get_width(), texture.get_height())

		piece.piece_scale = image_size / Vector2(cols, rows) * scale

		# we don't use a dictionary here since different values gives better editing in the editor UI
		piece.top_hinge = 2 if neighbors.top == null else _reverse_hinge(neighbors.top.bottom_hinge)
		piece.left_hinge = 2 if neighbors.left == null else _reverse_hinge(neighbors.left.right_hinge)
		piece.right_hinge = 2 if piece.col == cols - 1 else randi() % 2
		piece.bottom_hinge = 2 if piece.row == rows - 1 else randi() % 2

		piece.position = (piece.piece_scale * 2 * Vector2(piece.col, piece.row)) - (image_size * scale)

		pieces.append(piece)

	for piece in pieces:
		add_child(piece)

the pieces themselves

We’ll make a new scene, puzzle/PuzzlePiece.tscn, that is a Polygon2D (so we can use the texture property) with a script attached to it.

From our earlier script above, lets first capture our exported variables. Instead of using a dictionary for the hinges, we’ll use an enum, so we can use the editor UI to easily set the values instead of dealing with dictionary editing.

extends Polygon2D

enum HingeState {
	EXTENDED = 0,
	CONTRACTED = 1,
	NONE = 2
}

export (int) var rows
export (int) var cols

export (int) var row
export (int) var col

export (Texture) var texture

export(HingeState) var top_hinge = HingeState.NONE
export(HingeState) var left_hinge = HingeState.NONE
export(HingeState) var right_hinge = HingeState.NONE
export(HingeState) var bottom_hinge = HingeState.NONE

Next, we can begin generating the hinge.

func hinge(type: int, direction: Vector2) -> PoolVector2Array:
	# this is technically a "right hinge", so we can rotate it to be whatever hinge we want

	var angle := direction.angle()
	var pool = PoolVector2Array()

	# because puzzle pieces can be oriented differently we need to swap width and height depending on the direction
	var current_scale := piece_scale if direction.y == 0 else Vector2(piece_scale.y, piece_scale.x)
	pool.append(current_scale.rotated(angle))

	# since our puzzle piece is around (0, 0), we can use current_scale / 4 to define the hinge boundaries
	if type != HingeState.NONE:
		pool.append_array([
			Vector2(current_scale.x, current_scale.y / 4).rotated(angle),
			Vector2(current_scale.x + current_scale.x / 2 * sign(type - 0.5), current_scale.y / 4).rotated(angle),
			Vector2(current_scale.x + current_scale.x / 2 * sign(type - 0.5), -current_scale.y / 4).rotated(angle),
			Vector2(current_scale.x, -current_scale.y / 4).rotated(angle),
		])

	return pool

We can then use this function to generate the hinges for each side of the puzzle piece:

func _ready() -> void:
	polygon = (
		hinge(right_hinge, Vector2.RIGHT)
		+ hinge(top_hinge, Vector2.UP)
		+ hinge(left_hinge, Vector2.LEFT)
		+ hinge(bottom_hinge, Vector2.DOWN)
	)

Finally, we can map each vertex to a UV coordinate so we can use the texture:

func _ready() -> void:
	...

	# we keep track of our own UV array since we can't append to it directly (the getter returns a clone)
	var local_uv := []

	var image_width: int = texture.get_width() / cols
	var image_height: int = texture.get_height() / rows

	for vertex in polygon:
		var normalized_vertex: Vector2 = (vertex / (piece_scale)) * (Vector2(image_width, image_height) / 2)
		local_uv.append(
			normalized_vertex
			+ Vector2(
				image_width / 2 + (image_width * col),
				image_height / 2 + (image_height * row)
			)
		)

	uv = local_uv

We get this final code:

extends Polygon2D

enum HingeState {
	EXTENDED = 0,
	CONTRACTED = 1,
	NONE = 2
}

export (int) var rows
export (int) var cols

export (int) var row
export (int) var col

export(HingeState) var top_hinge = HingeState.NONE
export(HingeState) var left_hinge = HingeState.NONE
export(HingeState) var right_hinge = HingeState.NONE
export(HingeState) var bottom_hinge = HingeState.NONE

export var piece_scale: Vector2

func hinge(type: int, direction: Vector2) -> PoolVector2Array:
	# this is technically a "right hinge", so we can rotate it to be whatever hinge we want

	var angle := direction.angle()
	var pool = PoolVector2Array()

	# because puzzle pieces can be oriented differently we need to swap width and height depending on the direction
	var current_scale := piece_scale if direction.y == 0 else Vector2(piece_scale.y, piece_scale.x)
	pool.append(current_scale.rotated(angle))

	# since our puzzle piece is around (0, 0), we can use current_scale / 4 to define the hinge boundaries
	if type != HingeState.NONE:
		pool.append_array([
			Vector2(current_scale.x, current_scale.y / 4).rotated(angle),
			Vector2(current_scale.x + current_scale.x / 2 * sign(type - 0.5), current_scale.y / 4).rotated(angle),
			Vector2(current_scale.x + current_scale.x / 2 * sign(type - 0.5), -current_scale.y / 4).rotated(angle),
			Vector2(current_scale.x, -current_scale.y / 4).rotated(angle),
		])

	return pool

func _ready() -> void:
	polygon = (
		hinge(right_hinge, Vector2.RIGHT)
		+ hinge(top_hinge, Vector2.UP)
		+ hinge(left_hinge, Vector2.LEFT)
		+ hinge(bottom_hinge, Vector2.DOWN)
	)

	# we keep track of our own UV array since we can't append to it directly (the getter returns a clone)
	var local_uv := []

	var image_width: int = texture.get_width() / cols
	var image_height: int = texture.get_height() / rows

	for vertex in polygon:
		var normalized_vertex: Vector2 = (vertex / (piece_scale)) * (Vector2(image_width, image_height) / 2)
		local_uv.append(
			normalized_vertex
			+ Vector2(
				image_width / 2 + (image_width * col),
				image_height / 2 + (image_height * row)
			)
		)

	uv = local_uv

To demonstrate, I’ve changed the piece.position = setter to multiply by 3 so you can see each individual jigsaw piece:

The final puzzle picture split into different pieces.

failed attempts

At first, I tried using an AtlasTexture, but that only splits images into squares, not into arbitrary shapes.

I also tried using a Sprite with a Polygon2D as a mask, but the solution for that is so convoluted that I attempted to find a better way before that happened.

When I was testing out the main solution, I made the inadvertent assumption that the origin (0, 0) for the texture was on the bottom left:

Deformed ferret puzzle

hell in non-square images

My original code for another game project involved only square images - when I went to adapt it to non-square images, I quickly realized I was using width and height interchangeably, everywhere, trying to figure out why the puzzle pieces were all over the place.