Quick answer: Store your puzzle state in an Array object as a 2D grid, spawn sprite instances at grid-calculated positions, implement match detection by scanning rows and columns for consecutive same-type pieces, use the Drag & Drop behavior or click-to-swap for player interaction, and add gravity to collapse the board after matches are removed. Construct 3’s Array, Tween, and Timer behaviors handle the heavy lifting.

Puzzle games are one of the most rewarding genres to build in Construct 3. The logic is discrete and grid-based, which maps naturally to arrays and event conditions. Whether you are building a match-3, a sliding puzzle, a Sokoban-style box pusher, or a color-flood game, the core techniques are the same: a data model that tracks the board state, sprites that visualize it, and rules that validate moves and detect solutions. This guide walks through building a match-3 puzzle game, but the patterns apply to any grid-based puzzle.

Setting Up the Grid Data Model

The foundation of any puzzle game is a data structure that represents the board independently of the visuals. In Construct 3, use an Array object for this. Create an Array object and set its width to the number of columns (e.g., 8) and height to the number of rows (e.g., 8). Each cell stores a number representing the piece type (0 through 5 for six gem colors).

// Grid constants
// Define these as global variables or instance variables

// GridCols:     8     (number of columns)
// GridRows:     8     (number of rows)
// CellSize:     64    (pixel size of each cell)
// OriginX:      96    (left edge of grid in layout)
// OriginY:      64    (top edge of grid in layout)
// NumTypes:     6     (number of distinct piece types)

// Initialize the grid with random values
System: On start of layout
System: For "col" from 0 to GridCols - 1
System: For "row" from 0 to GridRows - 1GridArray: Set value at (loopindex("col"), loopindex("row"))
      to floor(random(NumTypes))System: Create Gem on layer "Game"
      at (OriginX + loopindex("col") * CellSize + CellSize/2,
           OriginY + loopindex("row") * CellSize + CellSize/2)

Store the column and row as instance variables on each Gem sprite so you can always map between the visual sprite and its position in the array. When the grid changes, update the array first, then update the sprites to match.

Preventing Initial Matches

A randomly generated board will almost certainly contain pre-existing matches, which feels unfair to the player. After filling the grid, scan for matches and re-roll any cell that creates one:

// Prevent initial matches during board generation
// After setting each cell, check if it creates a match

// For each cell at (col, row):
// Check horizontal: does GridArray.At(col,row) equal both
//   GridArray.At(col-1,row) and GridArray.At(col-2,row)?
// Check vertical: does GridArray.At(col,row) equal both
//   GridArray.At(col,row-1) and GridArray.At(col,row-2)?
// If either is true, re-roll with a different type

Function "SafeRandomType" (col, row)
    Local candidate = floor(random(NumTypes))
    While: (col ≥ 2
            AND candidate = GridArray.At(col-1, row)
            AND candidate = GridArray.At(col-2, row))
        OR (row ≥ 2
            AND candidate = GridArray.At(col, row-1)
            AND candidate = GridArray.At(col, row-2))
        → Set candidate to (candidate + 1) % NumTypesReturn candidate

Implementing Swap Mechanics

In a classic match-3, the player selects two adjacent pieces and swaps them. If the swap creates a match, it stays; otherwise, it reverses. Track the selected piece and validate adjacency before performing the swap:

// Click-to-swap mechanic
// Instance variables on Gem: Col, Row, Type
// Global variables: SelectedCol = -1, SelectedRow = -1

Gem: On clicked
System: SelectedCol = -1
    // First selectionSet SelectedCol to Gem.ColSet SelectedRow to Gem.RowGem: Set scale to 1.15
      // Visual feedback for selection

Gem: On clicked
System: SelectedCol ≠ -1
    // Second selection - check adjacency
    System: abs(Gem.Col - SelectedCol) + abs(Gem.Row - SelectedRow) = 1
        // Adjacent - perform swapCall "SwapPieces" (SelectedCol, SelectedRow, Gem.Col, Gem.Row)
        → Set SelectedCol to -1

    System: Else
        // Not adjacent - reset selectionSet SelectedCol to -1

The swap function updates both the array and the visual positions. Use the Tween behavior on the Gem sprites to animate the swap smoothly over 0.2 seconds rather than teleporting pieces:

// Animate the swap with Tween
Function "SwapPieces" (col1, row1, col2, row2)
    // Swap values in the arrayLocal temp = GridArray.At(col1, row1)
    → GridArray: Set at (col1, row1) to GridArray.At(col2, row2)
    → GridArray: Set at (col2, row2) to temp

    // Find the two gem sprites and tween their positions
    // Gem1: pick by Col=col1, Row=row1Gem: Tween "Position" to
      (OriginX + col2 * CellSize + CellSize/2,
       OriginY + row2 * CellSize + CellSize/2)
      in 0.2 seconds, ease "OutBack"Gem: Set Col to col2Gem: Set Row to row2

    // Same for the second gem, tweening to (col1, row1)

Match Detection

After every swap (and after gravity settles), scan the entire board for matches. A match is three or more consecutive cells in a row or column with the same type:

// Horizontal match scan
Function "FindMatches"
    // Create a parallel MatchArray (same size as GridArray)
    // initialized to 0. Mark cells as 1 if they are part of a match.

    // Scan rows for horizontal matches
    System: For "row" from 0 to GridRows - 1
    System: For "col" from 0 to GridCols - 3
    System: GridArray.At(col, row) = GridArray.At(col+1, row)
    System: GridArray.At(col, row) = GridArray.At(col+2, row)
        → MatchArray: Set at (col, row) to 1MatchArray: Set at (col+1, row) to 1MatchArray: Set at (col+2, row) to 1

    // Scan columns for vertical matches
    System: For "col" from 0 to GridCols - 1
    System: For "row" from 0 to GridRows - 3
    System: GridArray.At(col, row) = GridArray.At(col, row+1)
    System: GridArray.At(col, row) = GridArray.At(col, row+2)
        → MatchArray: Set at (col, row) to 1MatchArray: Set at (col, row+1) to 1MatchArray: Set at (col, row+2) to 1

After marking all matched cells, remove them, add to the score, and trigger gravity. If no matches are found after a swap, reverse the swap to indicate an invalid move.

Gravity and Refilling

When pieces are removed, the pieces above them need to fall down. Process each column from bottom to top, shifting pieces down to fill gaps:

// Apply gravity after removing matches
Function "ApplyGravity"
    System: For "col" from 0 to GridCols - 1
        Local writeRow = GridRows - 1

        // Scan from bottom to top
        System: For "row" from GridRows - 1 to 0
        System: GridArray.At(col, loopindex("row")) ≠ -1
            // -1 means empty cellGridArray: Set at (col, writeRow)
              to GridArray.At(col, loopindex("row"))
            → Decrement writeRow

        // Fill remaining top cells with new random types
        System: For "fill" from 0 to writeRowGridArray: Set at (col, loopindex("fill"))
              to floor(random(NumTypes))

    // Rebuild visual sprites to match new grid stateCall "RebuildSprites"
    // After sprites settle, check for cascading matchesSystem: Wait 0.4 secondsCall "FindMatches"

Scoring and Level Progression

Award base points per matched gem and multiply by a combo counter that increments with each cascade. Display the score with a satisfying counting-up animation using lerp on a text object:

// Scoring system
// Global variables: Score, DisplayScore, ComboCount

// When matches are found:
Function "AwardPoints" (matchedCount)
    → Add ComboCount to ComboCountAdd matchedCount * 10 * ComboCount to Score

// Smooth score display
System: Every tickSet DisplayScore to
      lerp(DisplayScore, Score, 10 * dt)ScoreText: Set text to
      "Score: " & round(DisplayScore)

// Level progression
// Increase target score each level, reduce move limit
// Level 1: 500 points, 20 moves
// Level 2: 1200 points, 18 moves
// Level 3: 2000 points, 16 moves

Add move limits or a countdown timer to create pressure. When the player runs out of moves, check if they reached the target score. If so, advance to the next level. If not, show a retry screen.

Visual Polish

Puzzle games live or die by their visual feedback. Use the Tween behavior for every movement: pieces should ease into position, not teleport. When gems are matched, scale them up briefly, flash white, then shrink to zero before being destroyed. Spawn a Particles object at each matched gem’s position for a burst of sparkles. Play a short chime sound on each match, pitching it up slightly for each step in a combo chain to create a satisfying musical scale.

Related Issues

If your gems are snapping to the wrong grid positions, see Fix: Construct 3 Array Index Off by One. For pieces falling through gaps incorrectly, check Fix: Construct 3 Gravity Logic Skipping Cells. If tweened animations overlap and cause visual glitches, see Fix: Construct 3 Tween Conflict on Same Property.

Always update the data model first, then sync the visuals. Chasing sprite positions leads to bugs.