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.
In the previous chapter, circles began accumulating across multiple games. Now add a second mode: Auto has no finish line, and new circles keep appearing, up to a maximum of 12 on the board. You can still drag each circle manually, and each collection still updates both counters.
This chapter is significantly longer than the previous chapters. Take your time.
The result we want to see
- In normal mode, Start runs the same short game we already built.
- Turning Auto on stops the short round and immediately starts an endless board with three circles.
- A new circle appears about once every five seconds, but there will never be more than 12 circles on the screen.
- Turning Auto off returns to the screen waiting for Start.
- The choice persists: if you close the app with Auto on, it returns to that mode next time.
“Endless” does not mean creating infinitely many objects. The game can continue indefinitely because collected circles are removed and new ones are created, while the number present at any moment stays limited.
1. Save the game mode too
Open GameProgress.java. Add a key and a field:
// Key used to persist the selected game mode in SharedPreferences.
private static final String AUTONOMOUS_MODE_KEY = "autonomous_mode";
// Cached mode used by the current screen.
private boolean autonomousMode;
Load it at the end of the constructor. The default is false, so an existing installation initially opens in the familiar game mode:
autonomousMode = preferences.getBoolean(AUTONOMOUS_MODE_KEY, false);
Add getter and setter:
/**
* Returns the game mode currently selected by the player.
*/
public boolean isAutonomousMode() {
return autonomousMode;
}
/**
* Updates the selected mode and saves it for the next app launch.
*/
public void setAutonomousMode(boolean autonomousMode) {
this.autonomousMode = autonomousMode;
preferences.edit()
.putBoolean(AUTONOMOUS_MODE_KEY, autonomousMode)
.apply();
}
A comment beginning with /** is Javadoc. Android Studio shows it to callers, and tags such as @param, @return, and @throws document the method’s contract where it is needed.
The setter updates both the field used by the current screen and the saved value loaded next time.
2. Add a switch without dedicating an entire row to it
Replace the single Start button with a row containing Start and the Auto switch. This adds a choice without taking more space from the game board. Make these changes in activity_main.xml.
Before
<!-- Starts a new round and starts timing. -->
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="@string/start"
android:textSize="16sp"
app:cornerRadius="14dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressRow" />
<!-- Places the two notification buttons side by side to save vertical space. -->
<LinearLayout
android:id="@+id/notificationActionsRow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/startButton">
<!-- Requests notification permission if needed and displays a local test notification. -->
<com.google.android.material.button.MaterialButton
android:id="@+id/localNotifButton"
android:layout_width="0dp"
After
+ <!-- displays the Start button and the switch for the autonomous game mode in the same row. -->
+ <LinearLayout
+ android:id="@+id/gameModeRow"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:gravity="center_vertical"
+ android:orientation="horizontal"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/progressRow">
+
<!-- Starts a new round and starts timing. -->
<com.google.android.material.button.MaterialButton
android:id="@+id/startButton"
android:layout_width="0dp"
android:layout_height="wrap_content"
- android:layout_marginTop="8dp"
+ android:layout_marginEnd="8dp"
+ android:layout_weight="1"
android:text="@string/start"
android:textSize="16sp"
- app:cornerRadius="14dp"
- app:layout_constraintEnd_toEndOf="parent"
- app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/progressRow" />
+ app:cornerRadius="14dp" />
+
+ <!-- Turns the standalone game mode on or off. -->
+ <com.google.android.material.materialswitch.MaterialSwitch
+ android:id="@+id/autonomousSwitch"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/autonomous_mode"
+ android:textColor="@color/title_purple"
+ android:textStyle="bold" />
+
+ </LinearLayout>
<!-- Places the two notification buttons side by side to save vertical space. -->
<LinearLayout
android:id="@+id/notificationActionsRow"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/startButton">
+ app:layout_constraintTop_toBottomOf="@id/gameModeRow">
<!-- Requests notification permission if needed and displays a local test notification. -->
<com.google.android.material.button.MaterialButton
android:id="@+id/localNotifButton"
android:layout_width="0dp"
In strings.xml add:
<string name="autonomous_mode">Auto</string>
<string name="autonomous_time">Time: autonomous</string>
3. Teach Game to create two types of game
Now work on the Game class.
Until now, the constructor always created five circles using CIRCLE_COUNT. To support both modes, replace that constant with four constants, add a boolean field recording whether the game is autonomous, and add a fractional accumulator for circle spawning:
-private static final int CIRCLE_COUNT = 5;
+// Number of circles created for a regular timed game.
+private static final int TIMED_CIRCLE_COUNT = 5;
+// Number of circles shown when Auto mode starts.
+private static final int AUTONOMOUS_STARTING_CIRCLE_COUNT = 3;
+// Maximum number of circles allowed on the board at once.
+static final int MAX_CIRCLE_COUNT = 12;
+// Fraction of a new circle earned during one second of Auto mode.
+static final double AUTONOMOUS_SPAWN_RATE = 0.20;
private final Random random = new Random();
private final Target target;
private final List<Circle> circles = new ArrayList<>();
+// True when this model represents an endless Auto game.
+private final boolean autonomous;
private Circle selectedCircle;
+// Stores partial spawn progress until it reaches one complete circle.
+private double spawnProgress;
Update the end of the constructor signature and the circle creation call:
+/**
+ * Creates a timed or Auto game with the correct number of starting circles.
+ */
public Game(float boardWidth, float boardHeight, float targetRadius,
int targetColor, int crossColor, float crossStrokeWidth,
- int circleColor) {
+ int circleColor, boolean autonomous) {
+ // Keep the selected mode for update() and isFinished().
+ this.autonomous = autonomous;
// Keep the existing Target construction unchanged.
- createCircles(boardWidth, boardHeight, circleColor);
+ int startingCircleCount = autonomous
+ ? AUTONOMOUS_STARTING_CIRCLE_COUNT
+ : TIMED_CIRCLE_COUNT;
+ createCircles(startingCircleCount, boardWidth, boardHeight, circleColor);
}
Immediately after changing the constructor signature, Android Studio will temporarily mark new Game(...) in GameBoardView in red because it lacks the new boolean argument. You have not made a mistake: in step 4, we will update game creation and pass the appropriate value to the constructor.
Also update the creation method’s signature and loop condition:
/**
* Adds non-overlapping circles until the requested amount is reached.
*/
private void createCircles(int requestedCount, float boardWidth,
float boardHeight, int circleColor) {
float circleRadius = target.getRadius() / 2f;
while (circles.size() < requestedCount) {
// Keep the existing loop body unchanged.
}
}
Now add the model’s update loop:
/**
* 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(double elapsedSeconds, float boardWidth,
float boardHeight, int circleColor) {
if (!autonomous) {
return;
}
spawnProgress += elapsedSeconds * AUTONOMOUS_SPAWN_RATE;
while (spawnProgress >= 1.0 && circles.size() < MAX_CIRCLE_COUNT) {
createCircles(circles.size() + 1, boardWidth, boardHeight, circleColor);
spawnProgress -= 1.0;
}
if (circles.size() == MAX_CIRCLE_COUNT) {
spawnProgress = Math.min(spawnProgress, 1.0);
}
}
spawnProgress is a fractional accumulator. At a rate of 0.20, it gains one fifth of a circle each second. After five seconds, it reaches 1 and a whole circle can be created. When the board is full, we do not accumulate a “debt” of circles that would flood the screen as soon as space becomes available.
Add a small method that will help the tests:
/**
* Returns the number of circles currently on the board.
*/
int getCircleCount() {
return circles.size();
}
And finally make sure that an autonomous game never reports that it’s finished:
+/**
+ * Reports completion only for an empty timed game; Auto never ends itself.
+ */
public boolean isFinished() {
- return circles.isEmpty();
+ return !autonomous && circles.isEmpty();
}
4. Add a frame loop to GameBoardView
Canvas draws only when Android calls onDraw. To make circles appear even without touch input, request another frame using postOnAnimation().
Add import:
import android.os.SystemClock;
Add fields:
// True while the Auto frame callback should keep scheduling itself.
private boolean autonomousRunning;
// Timestamp of the previous frame, used to measure real elapsed time.
private long previousFrameMillis;
Add a Runnable that calculates the actual elapsed time:
// Repeats once per display frame while Auto mode is running.
private final Runnable autonomousFrame = new Runnable() {
/**
* Advances the model by one frame and requests the next drawing pass.
*/
@Override
public void run() {
if (!autonomousRunning || game == null) {
return;
}
long nowMillis = SystemClock.elapsedRealtime();
double elapsedSeconds = (nowMillis - previousFrameMillis) / 1000.0;
previousFrameMillis = nowMillis;
game.update(elapsedSeconds, getWidth(), getHeight(), color(R.color.circle_green));
invalidate();
postOnAnimation(this);
}
};
Change the normal game creation so that it stops a previous loop, uses the helpers we’ll add right after, and passes false to the constructor:
Before
/**
* Starts a new round using the game board’s current size.
* If the board has not been measured yet, the method leaves the game unchanged.
*/
public void startNewGame() {
if (getWidth() == 0 || getHeight() == 0) {
return;
}
float targetRadius = Math.min(
dp(TARGET_RADIUS_DP),
Math.min(getWidth(), getHeight()) / 5f
);
game = new Game(
getWidth(),
getHeight(),
targetRadius,
color(R.color.target_red),
color(R.color.target_cross),
dp(2f),
color(R.color.circle_green)
);
invalidate();
}
After
-/**
- * Starts a new round using the game board’s current size.
- * If the board has not been measured yet, the method leaves the game unchanged.
- */
+/**
+ * Starts a timed game and stops any previous autonomous frame loop.
+ */
public void startNewGame() {
+ stopAutonomousFrames();
if (getWidth() == 0 || getHeight() == 0) {
return;
}
- float targetRadius = Math.min(
- dp(TARGET_RADIUS_DP),
- Math.min(getWidth(), getHeight()) / 5f
- );
- game = new Game(
- getWidth(),
- getHeight(),
- targetRadius,
- color(R.color.target_red),
- color(R.color.target_cross),
- dp(2f),
- color(R.color.circle_green)
- );
+ game = createGame(calculateTargetRadius(), false);
invalidate();
}
To avoid duplicating the color list in two methods, extract model creation:
/**
* 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
);
}
Add the autonomous mode methods:
/**
* Creates a fresh Auto game and starts its frame loop.
*/
public void startAutonomousGame() {
if (getWidth() == 0 || getHeight() == 0) {
return;
}
float targetRadius = calculateTargetRadius();
game = createGame(targetRadius, true);
resumeAutonomousGame();
invalidate();
}
/**
* Resumes frame updates without replacing the current game.
*/
public void resumeAutonomousGame() {
if (game == null || autonomousRunning) {
return;
}
autonomousRunning = true;
previousFrameMillis = SystemClock.elapsedRealtime();
postOnAnimation(autonomousFrame);
}
/**
* Pauses frame updates while preserving the current game.
*/
public void pauseAutonomousGame() {
stopAutonomousFrames();
}
/**
* Stops all updates and removes the current game from the board.
*/
public void clearGame() {
stopAutonomousFrames();
game = null;
invalidate();
}
/**
* Cancels the scheduled callback so only one Auto loop can exist.
*/
private void stopAutonomousFrames() {
autonomousRunning = false;
removeCallbacks(autonomousFrame);
}
Also extract the radius calculation removed from startNewGame() into a method, so both modes use exactly the same target size:
/**
* Calculates the target size shared by timed and Auto games.
*/
private float calculateTargetRadius() {
return Math.min(
dp(TARGET_RADIUS_DP),
Math.min(getWidth(), getHeight()) / 5f
);
}
Note: The game compiles again at this point.
5. Connect the switch in MainActivity
After showProgress() in onCreate, set the switch state before attaching its listener. This prevents the initial load from being treated as a user change:
// Restore the saved switch position before attaching its listener.
binding.autonomousSwitch.setChecked(gameProgress.isAutonomousMode());
// Build the screen that matches the restored mode.
showSelectedGameMode();
Add a listener next to the listeners of the buttons:
// Persist mode changes and immediately rebuild the game screen.
binding.autonomousSwitch.setOnCheckedChangeListener((button, enabled) -> {
gameProgress.setAutonomousMode(enabled);
showSelectedGameMode();
});
And add the method that switches between the modes:
/**
* Stops the previous mode and configures the selected game mode.
*/
private void showSelectedGameMode() {
// Removes every queued execution of this exact Runnable from the View's message queue.
// This stops future timer updates but does not delete or disable timerUpdate,
// so the same Runnable can be started again later with timerUpdate.run().
binding.elapsedTimeText.removeCallbacks(timerUpdate);
gameRunning = false;
if (gameProgress.isAutonomousMode()) {
binding.startButton.setEnabled(false);
binding.elapsedTimeText.setText(R.string.autonomous_time);
// Wait until GameBoardView has valid width and height measurements.
binding.gameBoard.post(() -> {
if (gameProgress.isAutonomousMode()) {
binding.gameBoard.startAutonomousGame();
}
});
} else {
binding.startButton.setEnabled(true);
binding.elapsedTimeText.setText(R.string.elapsed_time_initial);
binding.gameBoard.clearGame();
}
}
Check isAutonomousMode() again because the Runnable executes only after the View has been measured. If the user turns Auto off in that brief interval, the outdated request must not accidentally start an autonomous game.
In onStart, resume the normal game timer or the Auto loop:
+/**
+ * Resumes the update loop that belongs to the selected game mode.
+ */
@Override
protected void onStart() {
super.onStart();
- if (gameRunning) {
+ // Resume only the loop that belongs to the selected game mode.
+ if (gameProgress.isAutonomousMode()) {
+ binding.gameBoard.resumeAutonomousGame();
+ } else if (gameRunning) {
binding.elapsedTimeText.removeCallbacks(timerUpdate);
timerUpdate.run();
}
}
In onStop, stop both:
/**
* Stops both update loops while the Activity is not visible.
*/
@Override
protected void onStop() {
binding.elapsedTimeText.removeCallbacks(timerUpdate);
binding.gameBoard.pauseAutonomousGame();
super.onStop();
}
Stopping the frames in the background does not cancel the saved Auto mode. It only prevents the screen from drawing when it cannot be seen. In the offline-progress chapter, we will calculate progress for this time without drawing thousands of unnecessary frames.
6. Test the spawn rate and maximum circle count
In ExampleUnitTest.java add:
import static org.junit.Assert.assertEquals;
And add a test:
/**
* Verifies the Auto spawn rate and the maximum on-screen circle count.
*/
@Test
public void autonomousGameSpawnsSlowlyAndStopsAtTheScreenCap() {
Game game = new Game(1000, 1000, 100,
0, 0, 1, 0, true);
assertEquals(3, game.getCircleCount());
game.update(5.1, 1000, 1000, 0);
assertEquals(4, game.getCircleCount());
game.update(1000, 1000, 1000, 0);
assertEquals(Game.MAX_CIRCLE_COUNT, game.getCircleCount());
}
To make sure the new update loop doesn’t change the short game, also add:
/**
* Verifies that elapsed time does not add circles to a timed game.
*/
@Test
public void timedGameDoesNotSpawnMoreCircles() {
Game game = new Game(1000, 1000, 100,
0, 0, 1, 0, false);
game.update(1000, 1000, 1000, 0);
assertEquals(5, game.getCircleCount());
}
Run the UnitTests for the first time:
.\gradlew.bat testDebugUnitTest assembleDebug lintDebug
If the command does not work and returns JAVA_HOME is not set or cannot find java, try:
$env:JAVA_HOME = "C:\Program Files\Android\Android Studio\jbr"
$env:Path = "$env:JAVA_HOME\bin;$env:Path"
.\gradlew.bat testDebugUnitTest assembleDebug lintDebug
If this works, Android Studio’s JDK is installed but is not configured for a new terminal. To avoid entering those two setup lines every time, search Windows for Edit environment variables for your account and create a user variable named JAVA_HOME with this value:
C:\Program Files\Android\Android Studio\jbr
Then add this folder to the user Path variable:
C:\Program Files\Android\Android Studio\jbr\bin
Sign out of Windows and sign back in, then try the terminal again. Check that the shorter command now works without those two setup lines.
7. Manual inspection
- Open the app in normal mode. Check that Start still creates five circles and the timer works.
- Activate Auto. Start should be disabled, the time label should show
Time: autonomous, and three circles should appear immediately. - Wait about five seconds and make sure that another circle appears without touching the screen.
- Drag circles into the target. Circles and Lifetime should increase without a completion dialog appearing.
- Leave the game open until the board reaches 12 circles. Check that it never exceeds that limit.
- Turn Auto off. The board should clear and Start should become available again.
- Turn Auto on, close the app, and reopen it. The switch setting and autonomous board should return.
- Check that the notification buttons still fit on their row without covering the board.
Now there are two games in the same Activity: a short challenge that the user starts, and an autonomous game that continues to generate work. In the next chapter we will add a store and allow the circle balance to be spent on the first Pusher.
Continue to Chapter 10: The Pushers Shop
Gemini tutor
Open Gem: CollectCircles Idle Lab to get hints, diagnostic questions and explanations appropriate to the stage you are on.