DEV Community

Cover image for Devlog: How I Generate Levels for The Last Ball
Igor Stepanov
Igor Stepanov

Posted on

Devlog: How I Generate Levels for The Last Ball

I love puzzles. My first game on Playdate was Move and Match. It’s a minimalist puzzle with tricky simple levels. The game turned out to be surprisingly hardcore. Despite its apparent simplicity, it can sometimes drive you crazy.

Move and Match

For my next game, I decided to move away from that level of difficulty and create something simple, relaxing, and stress-relieving. So I chose a classic mechanic that I personally love: pushing balls around the field using a single ball.

You’ve probably seen similar games on your phones, but it’s unlikely that the experience felt relaxing. An overload of ads, the inability to undo moves, and cluttered interfaces prevent players from truly enjoying the gameplay.

I decided to fix these issues and create my own version of such a game on Playdate: The Last Ball.

The Lat Ball

I prepared 100 handcrafted levels. But I also wanted the game to offer an endless experience, somewhat similar to incremental games. So I added an infinite mode with procedurally generated levels.

In this article, I’ll explain the algorithm behind generating those levels.

Enjoy!

Gameplay

We have a rectangular field with balls: several white ones and one black one. We control the black ball.

Using directional inputs, we can push the black ball in one of four directions. It rolls until it hits another ball, pushing it forward while stopping itself. In turn, a white ball can also push another ball in its path.

If the black ball rolls off the edge of the field, we lose. The goal is to push all white balls off the field.

Levels are generated on the fly based on the last position of the black ball.

Gameplay of The Last Ball

The Task

Given the starting position of the black ball, place N white balls on the field so that the level is solvable and interesting.

The Solution

We construct the level in the same order in which it will be played.

We take the current position of the black ball and choose a random direction.

First position

Next, we choose how many tiles it will move on its first action. The offset can even be zero, since the black ball may remain in the same cell after a move.

Movement

End of move

We then place the first white ball next to the new position of the black ball, in the direction of movement.

Set ball

We record the history of this move.

Move 1:
The hero moved through these cells.
White balls moved through these cells.

First move

First move

Next, we select the new position of the black ball in the same way: by choosing a random direction and offset.

However, there is now a constraint. The new white ball must not be placed on cells already visited by the black ball.

For example, we cannot place the second white ball here, because those cells were already used by the black ball during the first move — this would create a contradiction.

Contradiction

Can we place new balls on cells previously occupied by other white balls?

For example, like this:

Can we place here?

Yes, but not always.

Let’s look at the first case:

Case

We are generating a ball for the second move, meaning its position corresponds to that moment. However, a white ball has already passed through the central line.

Let’s reconstruct the initial state of the level so that by the second move we end up in the situation shown above.

The initial level must look like this:

Level

Level

In other words, in the starting configuration, we place the ball one tile to the right.

2nd ball

Edge Case

We cannot expect a white ball to be at the edge of the field during the second move if that would imply it started outside the field.

Edge case

Move History

As we place new balls, the level accumulates a history of moves, represented as arrows on the field with their corresponding step numbers.

Move history

Move history

Whenever we calculate the initial position of new white balls, we must:

Shift them along the arrows from the white ball history if they land on them.
Check that white balls do not interfere with the black ball’s movement at any point in the move history.

That is, after shifting a ball, we verify whether it could intersect with the black ball during any step. If it could, this placement is invalid.

The key here is not to get lost and to clearly understand the full movement history.

Essentially, you need to reconstruct the positions of the balls at each step in the past and check for conflicts.

Important Detail

When placing a ball, it may shift across multiple cells following the history steps—and even in different directions—if after each shift it lands on another arrow corresponding to an earlier move.

Code Snippet

Here’s a piece of code that checks the move history when determining a valid white ball position:

-- ballsMovesTable - history of white ball movements
-- heroMovesTable - history of black ball movements

newBallPosition = {x = newHeroPosition.x + direction.x,
                   y = newHeroPosition.y + direction.y}

-- If the position is out of bounds, placement is impossible
if not pointInBounds(newBallPosition, bounds) then
   return false
end

h = #ballsMovesTable -- number of moves in history

-- Traverse the history backwards
for i = #ballsMovesTable, 1, -1 do
   ballsMove = ballsMovesTable[i]

   -- If we hit a previous white ball arrow
   if ballsMove[newBallPosition.x][newBallPosition.y] ~= nil then

      -- Before shifting, check that we don't intersect with the black ball
      -- in future steps
      for j = i + 1, h do
         if heroMovesTable[j][newBallPosition.x][newBallPosition.y] then
            return false
         end
      end

      -- Shift the ball along the arrow
      offsetX = ballsMove[newBallPosition.x][newBallPosition.y].x
      offsetY = ballsMove[newBallPosition.x][newBallPosition.y].y
      newBallPosition = {x = newBallPosition.x + offsetX,
                         y = newBallPosition.y + offsetY}
      if not pointInBounds(newBallPosition, bounds) then
         return false
      end
      h = i

   end
end

-- Check final position against black ball history
for j = h, 1, -1 do
   if heroMovesTable[j][newBallPosition.x][newBallPosition.y] then
      return false
   end
end

return true
Enter fullscreen mode Exit fullscreen mode

Final Touch

I also improved the algorithm slightly by adding weighting when choosing how far the black ball moves. I gave preference to longer moves.

This makes levels more interesting, because longer movements of the black ball tend to create more engaging situations.

Gameplay

Top comments (0)