Shaped crafting grid recipes in Godot 4
Match a Minecraft-style grid of placed ingredients to a recipe in Godot 4, cropping the pattern to its bounding box so it matches anywhere on the bench.
The player has a three-by-three bench in front of them. They put a stick in the middle cell, another stick just below it, and two planks across the top. That is a pickaxe. Shove those same two planks and two sticks into the bottom-left corner instead and it should still be a pickaxe, because the shape is what matters, not the absolute cells it happens to land in. Now they scatter the planks with a stick loose between them, and that should be nothing at all. Shaped crafting grid recipes in Godot 4 come down to turning a grid of placed items into a yes-or-no answer against a list of authored patterns, and the position-independence is the part that quietly eats an afternoon.
I'll say the boundary plainly up front so nobody feels misled. The free Crafting Lite addon is shapeless only. A RecipeLite is a bag of inputs and a bag of outputs; CraftingLite.craft(recipe) checks counts and does not care where anything sits. Shaped grid matching is a Pro-tier feature, listed as cut in the Lite README. So this post is about building the grid layer yourself on top of the Lite core, and the good news is that the core already hands you the two halves you need: a recipe Resource to hang the pattern on, and a duck-typed inventory contract to spend the ingredients through.
Shapeless first, because you already have it
It helps to pin down what shapeless means before contrasting it, since half of any crafting bench is shapeless anyway. Here is the Lite recipe, unchanged from the addon.
class_name RecipeLite
extends Resource
@export var id: String = ""
@export var title: String = ""
@export var icon: Texture2D
## Each input: {item: Resource, count: int}
@export var inputs: Array = []
## Each output: {item: Resource, count: int}
@export var outputs: Array = []
static func io(item: Resource, count: int) -> Dictionary:
return {"item": item, "count": count}Matching that is just counting. CraftingLite.block_reason walks recipe.inputs, asks the bound inventory has_item(item, count) for each line, and returns REASON_MISSING_INPUTS the moment one comes up short. Two sticks and three iron, anywhere in the bag, in any order. A campfire or an alchemy pot usually wants exactly this and nothing more. The grid only earns its complexity when the arrangement is supposed to carry meaning, like the difference between a sword with its planks stacked vertically and a hoe with its planks bent into an L. If your game never draws that distinction, stop here and use the Lite core as shipped.
A pattern the designer can actually type
For the shaped case I want the recipe authored as a small character grid, the way Minecraft's data files read, because a designer can eyeball it. One letter per cell, a period or a space for empty, plus a key mapping each letter to an item. I put that on a subclass so it inherits id, title, outputs, and the shapeless inputs for free, and only adds the shape.
class_name ShapedRecipe
extends RecipeLite
# Rows read top to bottom. "." or " " is an empty cell.
# ["PP",
# "S.",
# "S."] is planks-over-stick, left-justified.
@export var pattern: PackedStringArray
# Maps a pattern character to the item Resource it requires.
@export var key: Dictionary # { "P": planks_resource, "S": stick_resource }
func _is_empty(c: String) -> bool:
return c == "." or c == " " or c == ""Two things about that choice. Keeping pattern and key separate from the inherited inputs array means the same Resource can still answer 'do I have the raw materials at all' through the shapeless path, which is a cheap pre-filter before you bother with geometry. And leaving the outputs on the base class means that once a shaped match succeeds, you hand the recipe straight to code that already knows how to produce it. Nothing downstream has to learn a new type.
Reading the bench into the same shape
The bench is your UI's business, but whatever it is, you flatten it to a grid of item Resources and nulls, row-major, one entry per cell. A three-by-three bench is nine entries. The exact widget does not matter here. A GridContainer of drop targets that writes into a backing array works fine.
# grid[y][x] is either an item Resource or null.
# This is what your drag-drop cells write into.
var grid: Array = [
[planks, planks, null],
[stick, null, null],
[stick, null, null],
]Now the actual problem. That arrangement sits in the top-left, but a recipe should match it wherever the player builds it. Comparing the raw grid against the raw pattern would only match when both happen to be pinned to the same corner. So before comparing anything, you crop both down to their tight bounding box. Strip every fully empty row off the top and bottom, then every fully empty column off the left and right. Two arrangements that are the same shape land on the same cropped grid regardless of where they floated.
# Crop a grid of item-or-null down to the smallest box that holds
# every non-empty cell. Returns a new 2D array. Leaves input alone.
func _normalize(cells: Array) -> Array:
var min_x := 0x7fffffff
var min_y := 0x7fffffff
var max_x := -1
var max_y := -1
for y in cells.size():
var row: Array = cells[y]
for x in row.size():
if row[x] != null:
min_x = min(min_x, x)
min_y = min(min_y, y)
max_x = max(max_x, x)
max_y = max(max_y, y)
if max_x < 0:
return [] # grid is entirely empty
var out: Array = []
for y in range(min_y, max_y + 1):
var new_row: Array = []
for x in range(min_x, max_x + 1):
new_row.append(cells[y][x])
out.append(new_row)
return outDo the same crop to the authored pattern once, turning its rows and key into the same grid-of-Resources-and-null form, and the match becomes a flat comparison: same height, same width, and every cell holds the same item or is empty in both. That is the whole trick. Normalize both sides to their bounding box, then compare cell by cell.
func _pattern_to_grid(recipe: ShapedRecipe) -> Array:
var cells: Array = []
for line in recipe.pattern:
var row: Array = []
for i in line.length():
var c := line[i]
row.append(null if recipe._is_empty(c) else recipe.key.get(c))
cells.append(row)
return _normalize(cells)
func matches(recipe: ShapedRecipe, bench: Array) -> bool:
var want := _pattern_to_grid(recipe)
var got := _normalize(bench)
if want.size() != got.size():
return false
for y in want.size():
if want[y].size() != got[y].size():
return false
for x in want[y].size():
if want[y][x] != got[y][x]:
return false
return trueHanding the match back to the Lite core
Matching a shape tells you which recipe the player built. It does not spend anything. Consuming the ingredients and producing the output is the shapeless problem again, and Crafting Lite already solved that, so route back into it instead of rewriting the inventory dance. Populate the base-class inputs from the pattern, one line per non-empty cell summed by item, keep outputs as authored, and the untouched craft path does the rest through the same has_item / remove_item / add_item contract every Lite addon speaks.
# Once a bench matches, fold the placed cells into the shapeless
# inputs the Lite core already knows how to consume, then craft.
func craft_shaped(recipe: ShapedRecipe, bench: Array, crafting: CraftingLite) -> bool:
if not matches(recipe, bench):
return false
var counts: Dictionary = {} # item Resource -> total needed
for row in bench:
for item in row:
if item != null:
counts[item] = int(counts.get(item, 0)) + 1
recipe.inputs = []
for item in counts:
recipe.inputs.append(RecipeLite.io(item, counts[item]))
return crafting.craft(recipe) # block_reason -> consume -> produce -> emitThat reuse is the payoff for subclassing RecipeLite instead of inventing a parallel type. crafting.craft runs its own block_reason guard first, so even after a shape matches it will still refuse and emit craft_failed with REASON_MISSING_INPUTS if the bound bag somehow can't cover the tally. On success it fires the same crafted and craft_completed signals your existing listeners are already wired to. You built a geometry layer on top, and the spending along with the failure reasons and the events all stayed exactly where they were.
One honest tradeoff to close on. The bounding-box crop handles translation, and that is what a Minecraft-style bench needs, so it is where I stopped. It does not handle mirroring or rotation. A recipe authored as an L will not match its own mirror image, which for a pickaxe is correct (you want the handle on a specific side) and for a symmetric tool is an annoyance the player will feel. If you want rotation-invariant shapes, normalize the pattern to a canonical rotation (rotate all four ways, keep the lexicographically smallest) and do the same to the bench before comparing. That is a real chunk of extra code, and it is a fair place for a different developer to decide their game doesn't need it. Everything above gets you a working shaped bench that matches anywhere in the grid, built on a shapeless core that was already doing the boring half of the job.
Or use mine
Crafting Lite, free on GitHub · Crafting System, $3.74 (25% off)