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.
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.
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.
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.
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.
We then place the first white ball next to the new position of the black ball, in the direction of movement.
We record the history of this move.
Move 1:
The hero moved through these cells.
White balls moved through these cells.
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.
Can we place new balls on cells previously occupied by other white balls?
For example, like this:
Yes, but not always.
Let’s look at the first 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:
In other words, in the starting configuration, we place the ball one tile to the right.
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.
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.
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
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.



















Top comments (0)