Puzzle Game | Jetpack Compose Canvas | Android Studio | Kotlin

You have played this Sliding numbers game in childhood with basic mobiles where you need to slide the boxes with numbers to arrange them serially. This has multiple variation like you can have images or words in place of number but the logic is same as to arrange them in a manner. Today we will make this puzzle game in android using Jetpack Compose. We are using Jetpack compose Canvas to detect dragging gestures and drawing boxes. The idea is to show a grid of numbers from 1 to 8 and showing them in boxes in 3 rows and 3 columns where one place will be empty for sliding other boxes. In this grid data we will add data from 0 to 8 where 0 will show a empty space and rest data will show a box in its place.

Lets create a function to generate our grid data. This function will return us List of List of Integers where it has 3 row and 3 columns of numbers.

// Function to generate a shuffled 3x3 grid with numbers 1-8 and one empty space.
fun generateGrid(): List<List<Int>> {
    return (0..8).shuffled().chunked(3)
}

Now we need a function to find the position of empty box in the grid means the location of 0 in the grid based on which we will move our boxes. This function will take the grid as input and return as Pair of two Integers as row and column index of the element position.

// Function to find the position of the empty space in the grid.
fun findEmptyPosition(grid: List<List<Int>>): Pair<Int, Int> {
    grid.forEachIndexed { y, row ->
        row.forEachIndexed { x, number ->
            if (number == 0) return x to y
        }
    }
    throw IllegalStateException("No empty space found in the grid")
}

Now we need to create two variables to maintain state of grid and the empty space during recomposition.

// The grid is a 3x3 matrix of integers, where 0 represents the empty space.
var grid by remember { mutableStateOf(generateGrid()) }
var emptyPosition by remember { mutableStateOf(findEmptyPosition(grid)) }

After this we will use Box composable to show Canvas inside this box. In the Canvas composable we will use the pointerInput Modifier to detect our drag gestures. In this detectdragGestures function we get multiple lamda’s like onDragStart, onDragEnd and onDragCancel but we will be using onDrag only for our purpose. In this lamda we got two values as change and dragAmount as we drag on the screen.

At first we need to figure out the direction of drag so that we can move our box in that direction if there is a empty space. we need to create a enum class for the direction and and function to find the drag direction. This function will take the dragAmount Offset as input and return us the direction. In this function we are simply comparing the x and y values of the offset we providing. You can think of it like if our x is more than y means drag is in x-axis and if y is greater than x mean movement in y-axis.

enum class Direction { UP, DOWN, LEFT, RIGHT }

// Function to determine the direction of the swipe.
fun getDragDirection(dragAmount: Offset): Direction? {
    return when {
        //abs will always give +ve number either we pass +ve or -ve
        abs(dragAmount.x) > abs(dragAmount.y) -> {  //we are swiping in x-axis
            if (dragAmount.x > 0) Direction.RIGHT else Direction.LEFT
        }

        abs(dragAmount.y) > abs(dragAmount.x) -> {  //we are swiping in y-axis
            if (dragAmount.y > 0) Direction.DOWN else Direction.UP
        }

        else -> null
    }
}

Now we got the direction of drag so we need to find which box was actually moved. So create a function for this named findTouchedBox which will return us the coordinates in form of Pair and take following inputs:

  1. position :Offset – the point where user touched first
  2. gridSize : Int – size of our grid which is 9
  3. cellSize : Float – this is the area of our each box inside the canvas
// Function to find the box that was touched based on the touch position.
fun findTouchedBox(position: Offset, gridSize: Int, cellSize: Float): Pair<Int, Int>? {
    val x = (position.x / cellSize).toInt()
    val y = (position.y / cellSize).toInt()
    return if (x in 0 until gridSize && y in 0 until gridSize) x to y else null
}

Now we got our direction and the box we are moving in that direction but we need to figure out that we can only move that box if we have an empty box in the adjacent place in the particular direction. If we find the empty box in that direction adjacent to our moving box then we will simply update the positions of element in out grid because we are drawing the boxes based on our grid so after moving the box updated list will draw the empty space in place of the box we have moved.

Now lets create our complicated function in simple way which will do two things for our first is give us the updated grid and second is provide the new position of our empty space. This function will take following inputs :

  1. direction : Direction – the direction of drag
  2. emptyPosition : Pair<Int,Int> – it is the position of 0 in grid data
  3. touchedBox : Pair<Int,Int> – the position of the box which is touched by user

Initially we will create empty variables using these input value and at last we will pass these as return values. We will compare 4 scenarios based on the direction of move/drag. I will explain the one rest are the same. Suppose we are drag in UP direction then if the x of touched box is equal to x of empty box and y of empty is 1 more than the y of touched box then this mean that the empty space is exactly above the sliding box and we are free to slide the box above. Now simply exchange the x and y of empty space and touched box because now our empty space is in place of the box and our box is now in place of empty space.

// Function to attempt moving the touched box if it is adjacent to the empty space.
fun List<List<Int>>.tryMove(
    direction: Direction,
    emptyPosition: Pair<Int, Int>,
    touchedBox: Pair<Int, Int>
): Pair<List<List<Int>>, Pair<Int, Int>> {
    val (emptyX, emptyY) = emptyPosition
    val (touchedX, touchedY) = touchedBox
    val newGrid = this.map { it.toMutableList() }

    return when (direction) {
        Direction.UP -> if (touchedX == emptyX && touchedY == emptyY + 1) {
            // Move the box down if the empty space is directly below it.
            newGrid[emptyY][emptyX] = newGrid[touchedY][touchedX]
            newGrid[touchedY][touchedX] = 0
            newGrid to (touchedX to touchedY)
        } else this to emptyPosition

        Direction.DOWN -> if (touchedX == emptyX && touchedY == emptyY - 1) {
            // Move the box up if the empty space is directly above it.
            newGrid[emptyY][emptyX] = newGrid[touchedY][touchedX]
            newGrid[touchedY][touchedX] = 0
            newGrid to (touchedX to touchedY)
        } else this to emptyPosition

        Direction.LEFT -> if (touchedY == emptyY && touchedX == emptyX + 1) {
            // Move the box left if the empty space is directly to the left of it.
            newGrid[emptyY][emptyX] = newGrid[touchedY][touchedX]
            newGrid[touchedY][touchedX] = 0
            newGrid to (touchedX to touchedY)
        } else this to emptyPosition

        Direction.RIGHT -> if (touchedY == emptyY && touchedX == emptyX - 1) {
            // Move the box right if the empty space is directly to the right of it.
            newGrid[emptyY][emptyX] = newGrid[touchedY][touchedX]
            newGrid[touchedY][touchedX] = 0
            newGrid to (touchedX to touchedY)
        } else this to emptyPosition
    }
}

Now everything is ready inside our onDrag lamda, we only need to pass the values we got from our move function inside the variables we create earlier and draw the boxes inside the canvas based on our grid data.

// Drawing the grid inside a Canvas, and detecting swipe gestures.
Canvas(
modifier = Modifier
.size(300.dp)
.pointerInput(Unit) {
    detectDragGestures(
        onDragEnd = {
            // When drag ends, stop further detection until next swipe
        },
        onDragCancel = {
            // When drag cancels, stop further detection until next swipe
        },
        onDrag = { change, dragAmount ->
            // Determine the direction of the swipe based on the drag amount.
            val direction = getDragDirection(dragAmount)
            if (direction != null) {
                // Identify which box was touched.
                val touchedBox =
                    findTouchedBox(change.position, grid.size, size.width / 3f)
                if (touchedBox != null) {
                    // Move the box only if it is adjacent to the empty space.
                    val (newGrid, newEmptyPosition) = grid.tryMove(
                        direction,
                        emptyPosition,
                        touchedBox
                    )
                    grid = newGrid
                    emptyPosition = newEmptyPosition
                }
            }
        }
    )
}
) {
    // Draw the grid with the numbers.
    drawGrid(grid)
}

Now create our final function to draw the box with a number on it each time when user slide the boxes. We can create only one function to do this action but we are creating two extention function on DrawScope to draw the boxes. Inside first function we will loop through the grid data and use second function to draw the box in form of roundRectagle.

// Function to draw the entire grid.
fun DrawScope.drawGrid(grid: List<List<Int>>) {
    val cellSize = size.width / 3f
    grid.forEachIndexed { y, row ->
        row.forEachIndexed { x, number ->
            if (number != 0) {
                drawBoxWithNumber(
                    number = number,
                    x = x,
                    y = y,
                    cellSize = cellSize,
                    padding = 5.dp
                )
            }
        }
    }
}

// Function to draw an individual box with a number inside it.
fun DrawScope.drawBoxWithNumber(number: Int, x: Int, y: Int, cellSize: Float, padding: Dp) {
    val boxSize = cellSize - padding.toPx()
    val left = x * cellSize + padding.toPx()
    val top = y * cellSize + padding.toPx()

    drawRoundRect(
        color = Color.Green.copy(0.5f),
        topLeft = Offset(left, top),
        size = Size(boxSize, boxSize),
        cornerRadius = CornerRadius(16.dp.toPx(), 16.dp.toPx())
    )
    drawContext.canvas.nativeCanvas.drawText(
        number.toString(),
        left + boxSize / 2,
        top + boxSize / 1.5f,
        Paint().asFrameworkPaint().apply {
            isAntiAlias = true
            textSize = 40.sp.toPx()
            textAlign = android.graphics.Paint.Align.CENTER
            color = android.graphics.Color.BLACK
            typeface = android.graphics.Typeface.create("", android.graphics.Typeface.BOLD)
        }
    )
}

GitHub gist for this video code : here

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top