Hebrew version · English chapter index
Previous chapter · Next chapter
On a touch screen, swipe right for the next chapter and left for the previous chapter.
You can already buy Pushers in the shop, but so far only their count is visible. In this chapter, each Pusher becomes a small stick figure that walks across the board, moves its arms and legs, and turns around at the edge. We will connect it to circles in the next chapter, when we add pushing.
Why draw on Canvas?
- The game is already drawn entirely in
GameBoardView, so the character can use the same frame loop. - There is no need for image files or a sprite sheet in the first step.
- You can see how a character is built from a head, body, and lines for arms and legs.
- You can change size, color and orientation with simple numbers.
The height of the character will be 75% of the target’s radius. The small circle has a diameter equal to the radius of the target, so the entire figure remains smaller than the circle it will push in the future.
1. Create Pusher
In the Android window, under app > kotlin+java > com.example.collectcircles, create a Java Class named Pusher:
package com.example.collectcircles;
import android.graphics.Canvas;
import android.graphics.Paint;
public class Pusher {
private static final float WIDTH_PART_OF_HEIGHT = 0.50f;
private static final float SPEED_IN_HEIGHTS_PER_SECOND = 0.80f;
private float centerX;
private final float centerY;
private final float height;
private final int color;
private final long animationOffsetMillis;
private int direction = 1;
/**
* Creates one animated walker at the requested board position.
*
* @param centerX horizontal center on the board, in pixels
* @param centerY vertical center on the board, in pixels
* @param height character height, in pixels
* @param color color used to draw the character
* @param animationOffsetMillis phase offset that separates this animation
* from the other Pushers
*/
public Pusher(float centerX, float centerY, float height,
int color, long animationOffsetMillis) {
this.centerX = centerX;
this.centerY = centerY;
this.height = height;
this.color = color;
this.animationOffsetMillis = animationOffsetMillis;
}
}
direction is 1 when the character walks right and -1 when it walks left. The offset prevents all characters from swinging their legs at exactly the same moment.
2. Move the character’s center and turn at the edge
Add to Pusher:
/**
* Advances the walker and turns it around before it leaves the board.
*
* @param elapsedSeconds time since the previous frame, in seconds
* @param boardWidth current board width, in pixels
*/
public void update(double elapsedSeconds, float boardWidth) {
float speed = height * SPEED_IN_HEIGHTS_PER_SECOND;
centerX += direction * speed * elapsedSeconds;
// Clamp the center so the whole character remains inside the board.
float halfWidth = height * WIDTH_PART_OF_HEIGHT / 2f;
if (centerX > boardWidth - halfWidth) {
centerX = boardWidth - halfWidth;
direction = -1;
} else if (centerX < halfWidth) {
centerX = halfWidth;
direction = 1;
}
}
Speed is measured in “character heights per second”, so a larger character does not appear to crawl. After calculating movement, correct the position if its edge crossed the board boundary and reverse its direction.
Add getters without public for testing, because only tests in the same package need them:
/**
* Returns the current horizontal center for package-level unit tests.
*
* @return horizontal center on the board, in pixels
*/
float getCenterX() {
return centerX;
}
/**
* Returns the current walking direction for package-level unit tests.
*
* @return {@code 1} when walking right, or {@code -1} when walking left
*/
int getDirection() {
return direction;
}
3. Draw a body and move limbs
Add the drawing method:
/**
* Draws one animation frame of the stick figure.
*
* @param canvas destination on which the character is drawn
* @param paint reusable Paint configured by this method before drawing
* @param animationTimeMillis monotonic animation time, in milliseconds
*/
public void draw(Canvas canvas, Paint paint, long animationTimeMillis) {
// Configure the shared Paint for an outlined figure with rounded joints.
paint.setColor(color);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(Math.max(2f, height * 0.06f));
paint.setStrokeCap(Paint.Cap.ROUND);
// Derive every body landmark from height so the figure scales uniformly.
float headRadius = height * 0.12f;
float headY = centerY - height * 0.30f;
float shouldersY = centerY - height * 0.12f;
float hipsY = centerY + height * 0.14f;
float footY = centerY + height * 0.42f;
// Convert time to a repeating offset; the phase shift keeps Pushers out of sync.
double phase = (animationTimeMillis + animationOffsetMillis) / 140.0;
float swing = (float) Math.sin(phase) * height * 0.12f;
// Draw the head and a short nose that points in the walking direction.
canvas.drawCircle(centerX, headY, headRadius, paint);
canvas.drawLine(
centerX + direction * headRadius,
headY,
centerX + direction * headRadius * 1.55f,
headY,
paint
);
// The torso joins the bottom of the head to the hips.
canvas.drawLine(centerX, headY + headRadius, centerX, hipsY, paint);
// Opposite swing signs make the arms alternate instead of moving together.
canvas.drawLine(centerX, shouldersY,
centerX + direction * swing, centerY + height * 0.05f, paint);
canvas.drawLine(centerX, shouldersY,
centerX - direction * swing, centerY + height * 0.05f, paint);
// The legs use the same phase in opposite directions to create a walking step.
canvas.drawLine(centerX, hipsY,
centerX + swing, footY, paint);
canvas.drawLine(centerX, hipsY,
centerX - swing, footY, paint);
}
The head is a circle, the nose is a short line pointing in the walking direction, the body is a vertical line, and the arms and legs are four lines. sin returns values between -1 and 1, making swing move repeatedly from side to side. When one leg moves forward, the other moves backward.
The character uses STROKE, while circles use FILL. Each object configures Paint before drawing, so it does not accidentally leave the wrong style for the next object.
How does Canvas animation really work?
The Canvas draws a single image, not an animation
Canvas does not remember a character, arm, or leg from the previous frame, and does not move them itself. Each call to onDraw redraws the board’s entire current state. Motion appears when Android quickly displays a series of images with slightly different coordinates.
Do not erase the old leg or drag an already drawn line. Calculate where the leg should be now, then draw the new frame:
New time → Game state update → Drawing request → Coordinate calculation → Frame drawing
Separate update and draw
The two methods have different roles:
update(elapsedSeconds, ...)changes the game state: for examplecenterXanddirection.draw(canvas, paint, animationTimeMillis)reads the state and draws one image from it. It does not decide how much time has passed or pause to display the image.
In our implementation, both methods are tied to display frames: postOnAnimation requests a callback for the next frame, that callback calls update and invalidate(), and Android calls onDraw when appropriate to prepare an image. This does not guarantee one draw call for every physical screen refresh. A frame may be delayed or dropped, and several redraw requests may be combined.
Therefore draw is frame-dependent, but the game state does not depend on the number of frames:
- A 120Hz screen will generally have more updates and pictures than a 60Hz screen, but each
elapsedSecondswill be smaller. The distance per second will remain the same. - If a frame is missed, it is not drawn later. In the next update
elapsedSecondsincludes the extra time, anddrawimmediately draws the current position. drawdoes takeanimationTimeMillisinto account to calculate the position of the limbs in the current frame, but it is not allowed to advance a counter, change balance or decide that a task has been completed.
A short rule: update determines the world’s state now; draw determines how that state looks in the frame Android is drawing now.
So the walking speed is calculated according to real time and not according to the number of drawing calls. In addition, in the next chapter we will be able to replace the movement rules without rewriting the character drawing.
The two time values in the code
In this chapter time is used in two ways:
-
elapsedSecondsis the time since the previous frame. Multiply it by the speed to calculate the distance to be covered:centerX += direction * speed * elapsedSeconds;If a frame was delayed, the character will move slightly further in the next frame instead of appearing slower.
-
animationTimeMillisis monotonic time that keeps increasing. Use it to calculate the arms’ and legs’ swing. We useSystemClock.elapsedRealtime()rather than ordinary date and time, because adjusting the device clock should not make the animation jump.
Turn time into cyclic motion
The line:
double phase = (animationTimeMillis + animationOffsetMillis) / 140.0;
converts milliseconds into phase, a position within the cycle. Then:
float swing = (float) Math.sin(phase) * height * 0.12f;
Math.sin returns a value between -1 and 1. Multiplying by height * 0.12f turns it into a horizontal offset that fits the size of the character:
- When
swingis positive, one limb is sent to the right and the other to the left. - When
swingis 0, both limbs pass through the center of the body. - When
swingis negative, the directions are reversed.
A complete cycle of sin is 2π, so the divisor 140.0 creates a walking cycle of about 2π × 140, that is, about 880 milliseconds. A smaller divisor will speed up the swing of the limbs; An amplitude greater than 0.12f will increase the step length.
animationOffsetMillis moves each pusher to a different point in the cycle. They all use the same clock, but don’t move their arms and legs exactly at the same time.
Who’s asking for the next frame?
The animation loop in GameBoardView does a small amount of work each frame:
- Reads monotonic time and calculates
elapsedSeconds. - Calls
Game.update, which updates the position of the characters. - Calls
invalidate(), which asks Android to redraw. The call does not triggeronDrawimmediately. - Calls
postOnAnimation(...), to request a callback for the next screen frame. - When Android calls
onDraw, the animation time is transferred toGame.drawand from there toPusher.draw.
Do not add a long while loop or sleep inside update, draw, or onDraw. These block the UI thread: the screen cannot redraw, touch is not handled, and the app appears frozen. Each frame should do a little calculation, draw, and immediately return control to Android.
Recipe for another Canvas animation
When you want to animate another object:
- Keep only the state of the object in the fields, for example center, direction, speed and work phase.
- Update ongoing state in
updateusingelapsedSeconds. - For cyclic motion, convert time to phase and use
sin,cosor advance from 0 to 1. - In
draw, calculate the current frame’s coordinates and redraw the entire object. - Set the color, style, and line width the object needs in
Paint; do not assume the previous object left suitable settings. - Keep the update and draw methods short and without blocking code, I/O or creating a wait loop.
4. Store Pushers in Game
Add the field:
private final List<Pusher> pushers = new ArrayList<>();
Add the Pusher count and color parameters to the constructor after autonomous:
+/**
+ * Creates a game and restores its purchased Pushers only in Auto mode.
+ *
+ * @param autonomous whether this is an endless Auto game
+ * @param pusherCount number of purchased Pushers to restore
+ * @param pusherColor color used to draw the Pushers
+ */
public Game(...,
- int circleColor, boolean autonomous) {
+ int circleColor, boolean autonomous, int pusherCount,
+ int pusherColor) {
Expected temporary incompatibility: Immediately after changing the constructor signature, Android Studio will mark in red the places that still call new Game(...) with the old parameter list. There is no need to create another constructor just to silence the errors in the middle of the chapter.
- In section 6 we will update the creation of the
GamewithinGameBoardViewand also the call toGame.draw. - In section 7 we will update the call from
MainActivity; At the end of section 7 the application code will again be compatible and able to compile. - The unit tests will still use the old constructor. In section 8 we will also update them; Only at the end of section 8 should the entire project, including
testDebugUnitTest, compile and run.
After creating the circles, add:
if (autonomous) {
setPusherCount(pusherCount, boardWidth, boardHeight, pusherColor);
}
Now add the method that creates only the missing characters:
/**
* Adds missing Pushers without recreating workers already on the board.
*
* @param requestedCount total number of Pushers that should exist
* @param boardWidth current board width, in pixels
* @param boardHeight current board height, in pixels
* @param pusherColor color used to draw newly created Pushers
*/
public void setPusherCount(int requestedCount, float boardWidth,
float boardHeight, int pusherColor) {
if (!autonomous) {
return;
}
float pusherHeight = target.getRadius() * 0.75f;
while (pushers.size() < requestedCount) {
int index = pushers.size();
float usableWidth = Math.max(1f, boardWidth - pusherHeight);
float x = pusherHeight / 2f
+ (index * pusherHeight * 1.4f) % usableWidth;
float y = boardHeight
- pusherHeight * (0.65f + index % 3);
pushers.add(new Pusher(
x,
y,
pusherHeight,
pusherColor,
index * 90L
));
}
}
Pushers start in three horizontal lanes near the bottom of the board. With more than three Pushers, lanes repeat, but their x positions and animation offsets differ.
5. Update and draw them
Before
/**
* Draws the target and all circles still in play.
*
* @param canvas the canvas
* @param paint the color and style settings used for drawing
*/
public void draw(Canvas canvas, Paint paint) {
target.draw(canvas, paint);
for (Circle circle : circles) {
circle.draw(canvas, paint);
}
}
/**
* Updates circle spawning in Auto mode using elapsed time and the board limit.
*
* @param elapsedSeconds The time elapsed since the previous update, in seconds
* @param boardWidth The width of the game board in pixels
* @param boardHeight The height of the game board in pixels
* @param circleColor The color of the new circles
*/
public void update(...) {
// Creating circles only
}
After
-/**
- * Draws the target and all circles still in play.
- *
- * @param canvas the canvas
- * @param paint the color and style settings used for drawing
- */
+/**
+ * Draws the game and Pushers using a shared animation clock.
+ *
+ * @param canvas the canvas
+ * @param paint the color and style settings used for drawing
+ * @param animationTimeMillis monotonic animation time in milliseconds
+ */
-public void draw(Canvas canvas, Paint paint) {
+public void draw(Canvas canvas, Paint paint,
+ long animationTimeMillis) {
target.draw(canvas, paint);
for (Circle circle : circles) {
circle.draw(canvas, paint);
}
+ for (Pusher pusher : pushers) {
+ pusher.draw(canvas, paint, animationTimeMillis);
+ }
}
-/**
- * Updates circle spawning in Auto mode using elapsed time and the board limit.
- *
- * @param elapsedSeconds The time elapsed since the previous update, in seconds
- * @param boardWidth The width of the game board in pixels
- * @param boardHeight The height of the game board in pixels
- * @param circleColor The color of the new circles
- */
+/**
+ * Updates circle spawning in Auto mode and advances all Pushers.
+ *
+ * @param elapsedSeconds The time elapsed since the previous update, in seconds
+ * @param boardWidth The width of the game board in pixels
+ * @param boardHeight The height of the game board in pixels
+ * @param circleColor The color of the new circles
+ */
public void update(...) {
// Circle creation remains
+ // Advance each Pusher by the same elapsed frame time.
+ for (Pusher pusher : pushers) {
+ pusher.update(elapsedSeconds, boardWidth);
+ }
}
Pushers are drawn last, so they remain visible even when briefly passing over a circle. In the next chapter, we will replace free roaming with movement toward a selected circle.
6. Pass time and the Pusher count from the View
Add to colors.xml:
<color name="pusher_blue">#1565C0</color>
GameBoardView already reads SystemClock.elapsedRealtime() each frame. Pass that time to drawing too:
Before
game.draw(canvas, paint);After
game.draw(canvas, paint, SystemClock.elapsedRealtime());Change startAutonomousGame so that it gets the saved number:
+/**
+ * Starts an Auto board with every Pusher purchased so far.
+ *
+ * @param pusherCount number of purchased Pushers to place on the board
+ */
-public void startAutonomousGame() {
+public void startAutonomousGame(int pusherCount) {
Change createGame to accept the number and transfer both it and the color:
Before
/**
- * Builds either game mode with the same board measurements and colors.
*/
-private Game createGame(float targetRadius, boolean autonomous) {
return new Game(
getWidth(),
getHeight(),
targetRadius,
color(R.color.target_red),
color(R.color.target_cross),
dp(2f),
color(R.color.circle_green),
- autonomous
);
}
After
/**
+ * Builds either game mode with the same colors and requested Pusher count.
+ *
+ * @param targetRadius target radius, in pixels
+ * @param autonomous whether to create an Auto game
+ * @param pusherCount number of purchased Pushers to restore
+ * @return a new game model sized for the current board
*/
+private Game createGame(float targetRadius, boolean autonomous,
+ int pusherCount) {
return new Game(
getWidth(),
getHeight(),
targetRadius,
color(R.color.target_red),
color(R.color.target_cross),
dp(2f),
color(R.color.circle_green),
+ autonomous,
+ pusherCount,
+ color(R.color.pusher_blue)
);
}
In startNewGame, pass 0 Pushers for the short game:
- game = createGame(calculateTargetRadius(), false);
+ game = createGame(calculateTargetRadius(), false, 0);
In startAutonomousGame, pass through the count received by the method:
- game = createGame(targetRadius, true);
+ game = createGame(targetRadius, true, pusherCount);
To show a newly purchased Pusher without restarting the board, add this to GameBoardView:
/**
* Synchronizes the active game with the updated total of purchased Pushers.
* Only missing Pusher objects are added; existing ones are preserved.
*
* @param pusherCount total number of purchased Pushers
*/
public void setPusherCount(int pusherCount) {
if (game == null) {
return;
}
game.setPusherCount(
pusherCount,
getWidth(),
getHeight(),
color(R.color.pusher_blue)
);
invalidate();
}
7. Connect the number from MainActivity
In showSelectedGameMode, update how the autonomous game starts:
- binding.gameBoard.startAutonomousGame();
+ binding.gameBoard.startAutonomousGame(gameProgress.getPusherCount());
In showPusherShop(), inside the positive button’s click listener, add:
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(view -> {
if (gameProgress.buyPusher()) {
showProgress();
+ // Sync the active board with the updated saved Pusher count.
+ binding.gameBoard.setPusherCount(gameProgress.getPusherCount());
Toast.makeText(this, R.string.pusher_hired, Toast.LENGTH_SHORT).show();
dialog.dismiss();
}
});
The purchase already happens in the gameProgress.buyPusher() call on the if line:
- The method checks that there are enough circles and subtracts the price.
- It executes
pusherCount++and saves the new number inSharedPreferences. - Only if all of this is successful does it return
true, so you enter theifbody. - At this point
gameProgress.getPusherCount()already returns the updated total number. binding.gameBoard.setPusherCount(...)does not buy a Pusher or increment a counter. It synchronizes the active board with the total count, adding only the missing Pusher characters.
If Auto is off, there is currently no autonomous model to update and the action does nothing. The number of Pushers is still saved, and they will be created the next time Auto is activated.
8. Focused testing
Update both Game constructor calls in the ExampleUnitTest tests, adding 0, 0 as the final two arguments for the Pusher count and color.
Add a check for movement and border:
/**
* Verifies that a long frame turns a Pusher around at the right edge.
*/
@Test
public void pusherTurnsAroundAndStaysInsideTheBoard() {
Pusher pusher = new Pusher(90, 50, 40, 0, 0);
pusher.update(10, 100);
assertTrue(pusher.getCenterX() <= 90);
assertEquals(-1, pusher.getDirection());
}
Run once:
.\gradlew.bat testDebugUnitTest assembleDebug
9. Manual inspection
- Turn Auto on before buying a Pusher. Check that it starts with three circles and keeps spawning new ones, up to 12.
- When the balance is still less than 64, open Shop. Make sure that the displayed price is 64 and that the Hire pusher button is disabled, so that the purchase cannot be made.
- Turn off Auto, turn on short games and collect the circles into the target. Repeat this until the
Circlesbalance has at least 64. - Open Shop and buy the first Pusher. Check that the balance is below 64 and the Pushers counter has increased to 1.
- Activate Auto again and check that a small blue character appears, smaller than the green circles.
- Watch the arms and legs: they should alternate while walking.
- Wait until the character reaches an edge. It should stay inside the board, face the other way, and walk back.
- Turn Auto off and start a short game. The Pusher should not appear in timed mode.
Pushers are now animated characters, but only walkers. In the next chapter, each one will select an unassigned circle, walk toward it, move behind it, and push it until it is fully contained in the target.
Continue to Chapter 12: The Pushers Get to Work
Gemini tutor
Open Gem: CollectCircles Idle Lab to get hints, diagnostic questions and explanations appropriate to the stage you are on.