CollectCircles 8 - A persistent circle balance


Count circles, save progress, and free up screen space

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. Start the gesture in the text area, away from screen edges reserved for system gestures, and outside code or tables that scroll horizontally.

Until now, every game ended after five circles, and only the best time was saved. Now add long-term progress: each collected circle increases both a balance you can spend later and a counter that never decreases. By the end of the lesson, you can close and reopen the app and see that the counters persist.

What we will build

  • Circles — the current balance. In a later chapter, we will be able to spend circles from it.
  • Lifetime — the number of circles ever collected. A future purchase will not reduce it.
  • Pushers — the number of small workers we own. It starts at zero; we will learn to buy them later.
  • A compact action row, so the new information does not take too much space from the game board.

The short game stays the same: press Start, drag five circles into the target, and get a completion time. We add a layer of progress without replacing that game.

1. Create a class that saves the progress

In the Android window, under app > kotlin+java > com.example.collectcircles, create a Java Class named GameProgress:

package com.example.collectcircles;

import android.content.SharedPreferences;

public class GameProgress {

    // The keys name each value stored in SharedPreferences.
    private static final String CIRCLES_BALANCE_KEY = "circles_balance";
    private static final String LIFETIME_CIRCLES_KEY = "lifetime_circles";
    private static final String PUSHER_COUNT_KEY = "pusher_count";

    // This preferences object lets us read and save progress on the device.
    private final SharedPreferences preferences;

    // A copy of the progress in memory, for quick use while the application is running.
    private long circlesBalance;
    private long lifetimeCircles;
    private int pusherCount;

    public GameProgress(SharedPreferences preferences) {
        this.preferences = preferences;

        // Load the saved values. If there is no saved value yet, start from scratch.
        circlesBalance = preferences.getLong(CIRCLES_BALANCE_KEY, 0);
        lifetimeCircles = preferences.getLong(LIFETIME_CIRCLES_KEY, 0);
        pusherCount = preferences.getInt(PUSHER_COUNT_KEY, 0);
    }

    // Returns the number of circles available to spend in the shop.
    public long getCirclesBalance() {
        return circlesBalance;
    }

    // Returns the total number of circles collected since the player started playing.
    public long getLifetimeCircles() {
        return lifetimeCircles;
    }

    // Returns the number of Pushers purchased.
    public int getPusherCount() {
        return pusherCount;
    }

    // Updates both counters after the player collects one circle.
    public void recordCollectedCircle() {
        // Stop at the maximum value to prevent overflow into a negative number.
        if (circlesBalance < Long.MAX_VALUE) {
            circlesBalance++;
        }
        if (lifetimeCircles < Long.MAX_VALUE) {
            lifetimeCircles++;
        }

        // Save the new values on the device; apply writes them in the background.
        preferences.edit()
                .putLong(CIRCLES_BALANCE_KEY, circlesBalance)
                .putLong(LIFETIME_CIRCLES_KEY, lifetimeCircles)
                .apply();
    }
}

The class holds two types of information:

  1. The state in memory — the fields used while the application is running.
  2. The saved state — the values in SharedPreferences, which will remain even after closing the application.

The check against Long.MAX_VALUE is not expected to be important in a normal game, but it prevents a full counter from overflowing into a negative number.

Do not increment the counters in both Game and MainActivity. Only recordCollectedCircle() changes progress, so each collection adds exactly one circle.

2. Add a status row and make the notification buttons smaller

Full code of activity_main.xml: in this section it is better to copy the complete code and go to the changes at the end of the section in the Java file
<?xml version="1.0" encoding="utf-8"?>
<!-- Main Screen Container: Positions all components relative to each other using constraints. -->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@color/screen_background"
    android:paddingHorizontal="20dp"
    tools:context=".MainActivity">

    <!-- displays the game name at the top of the screen. -->
    <TextView
        android:id="@+id/titleText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:gravity="center"
        android:text="@string/game_title"
        android:textColor="@color/title_purple"
        android:textSize="26sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <!-- Displays the time elapsed since the start of the current round. -->
    <TextView
        android:id="@+id/elapsedTimeText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="12dp"
        android:gravity="start"
        android:text="@string/elapsed_time_initial"
        android:textColor="@color/time_brown"
        android:textSize="16sp"
        app:layout_constraintEnd_toStartOf="@id/bestTimeText"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/titleText" />

    <!-- shows the best time saved from the previous rounds. -->
    <TextView
        android:id="@+id/bestTimeText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:gravity="end"
        android:text="@string/best_time_initial"
        android:textColor="@color/title_purple"
        android:textSize="16sp"
        app:layout_constraintBaseline_toBaselineOf="@id/elapsedTimeText"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/elapsedTimeText" />

    <!-- arranges the three progress counters side by side in one row. -->
    <LinearLayout
        android:id="@+id/progressRow"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:gravity="center"
        android:orientation="horizontal"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/elapsedTimeText">

        <!-- shows the remaining circles available for future purchases. -->
        <TextView
            android:id="@+id/circlesBalanceText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="start"
            android:text="@string/circles_balance_initial"
            android:textColor="@color/title_purple"
            android:textStyle="bold" />

        <!-- shows how many circles have been collected in total since the start of the game. -->
        <TextView
            android:id="@+id/lifetimeCirclesText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="center"
            android:text="@string/lifetime_circles_initial"
            android:textColor="@color/time_brown" />

        <!-- shows the number of Pushers purchased; in this chapter the value is still zero. -->
        <TextView
            android:id="@+id/pushersText"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:gravity="end"
            android:text="@string/pushers_initial"
            android:textColor="@color/title_purple" />

    </LinearLayout>

    <!-- starts a new game 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" />

    <!-- arranges the two notifications buttons side by side and saves 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">

        <!-- asks for notifications permission if necessary and displays a local notification for testing. -->
        <com.google.android.material.button.MaterialButton
            android:id="@+id/localNotifButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginEnd="4dp"
            android:layout_weight="1"
            android:minWidth="0dp"
            android:text="@string/local_notification_short"
            android:textSize="12sp"
            app:cornerRadius="12dp" />

        <!-- Sends an invitation through Firebase to another device with the game installed. -->
        <com.google.android.material.button.MaterialButton
            android:id="@+id/inviteButton"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginStart="4dp"
            android:layout_weight="1"
            android:minWidth="0dp"
            android:text="@string/invite"
            android:textSize="12sp"
            app:cornerRadius="12dp" />

    </LinearLayout>

    <!-- The custom game board that draws the circles and handles dragging. -->
    <com.example.collectcircles.GameBoardView
        android:id="@+id/gameBoard"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="16dp"
        android:layout_marginBottom="20dp"
        android:minHeight="320dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/notificationActionsRow"
        tools:background="@color/board_background" />

</androidx.constraintlayout.widget.ConstraintLayout>
This is the detailed version. Review it to understand the overall change, then collapse it and continue to the changes in the Java file.

Open app > res > layout > activity_main.xml. The time row stays in place. Below it we will add three counters, and then we will arrange the two notifications buttons in the same row.

Make three changes to the XML:

  1. Add progressRow immediately before startButton.
  2. Replace startButton with the updated version, whose upper constraint points to progressRow.
  3. Replace the separate localNotifButton and inviteButton with the notificationActionsRow, which contains both buttons.

Keep gameBoard, which sits between them in the existing file. Immediately after the larger code example, we will update just one of its constraints.

Below is the XML for the three new or updated components. Because there are many small changes, the full file is also available in the collapsible section above.

<LinearLayout
    android:id="@+id/progressRow"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginTop="8dp"
    android:gravity="center"
    android:orientation="horizontal"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/elapsedTimeText">

    <TextView
        android:id="@+id/circlesBalanceText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="start"
        android:text="@string/circles_balance_initial"
        android:textColor="@color/title_purple"
        android:textStyle="bold" />

    <TextView
        android:id="@+id/lifetimeCirclesText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        android:text="@string/lifetime_circles_initial"
        android:textColor="@color/time_brown" />

    <TextView
        android:id="@+id/pushersText"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="end"
        android:text="@string/pushers_initial"
        android:textColor="@color/title_purple" />
</LinearLayout>

<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" />

<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">

    <com.google.android.material.button.MaterialButton
        android:id="@+id/localNotifButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="4dp"
        android:layout_weight="1"
        android:minWidth="0dp"
        android:text="@string/local_notification_short"
        android:textSize="12sp"
        app:cornerRadius="12dp" />

    <com.google.android.material.button.MaterialButton
        android:id="@+id/inviteButton"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginStart="4dp"
        android:layout_weight="1"
        android:minWidth="0dp"
        android:text="@string/invite"
        android:textSize="12sp"
        app:cornerRadius="12dp" />
</LinearLayout>

Update the top constraint of gameBoard:

-app:layout_constraintTop_toBottomOf="@id/inviteButton"
+app:layout_constraintTop_toBottomOf="@id/notificationActionsRow"

In strings.xml add:

<string name="circles_balance_initial">Circles: 0</string>
<string name="lifetime_circles_initial">Lifetime: 0</string>
<string name="pushers_initial">Pushers: 0</string>
<string name="circles_balance_format">Circles: %1$d</string>
<string name="lifetime_circles_format">Lifetime: %1$d</string>
<string name="pushers_format">Pushers: %1$d</string>
<string name="local_notification_short">Local notification</string>

layout_weight="1" divides the row width evenly. This way no additional purchase screen is required, and the game board still gets most of the height.

Test: The app should run at this point. Its functionality is unchanged, but the UI looks different.

3. Report every collected circle

Game already returns true when a circle has been released entirely within the target. GameBoardView should report the event to the Activity immediately, rather than waiting until all five circles have been collected.

+private Runnable onCircleCollected;
private Runnable onGameFinished;

+public void setOnCircleCollectedListener(Runnable listener) {
+    onCircleCollected = listener;
+}

// ACTION_UP
⁞
+if (collected && onCircleCollected != null) {
+    onCircleCollected.run();
+}
if (collected && game.isFinished()
        && onGameFinished != null) {
    onGameFinished.run();
}

Add the field and the new method, and in ACTION_UP, trigger the collection event before the game-finished event. The fifth circle causes two events: first the counters are increased, then the timer stops.

4. Connect the progress to MainActivity

Add a field:

private GameProgress gameProgress;

After creating preferences in onCreate, load and display the progress:

preferences = getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE);
+gameProgress = new GameProgress(preferences);
bestTimeMillis = preferences.getLong(BEST_TIME_KEY, NO_BEST_TIME);
showBestTime();
+showProgress();

Inside onCreate, after connecting the button inviteButton and immediately before the existing endgame event, add the line marked +:

        binding.localNotifButton.setOnClickListener(
                view -> requestPermissionAndShowNotification()
        );
        binding.inviteButton.setOnClickListener(view -> sendInvite());
+       binding.gameBoard.setOnCircleCollectedListener(this::recordCollectedCircle);
        binding.gameBoard.setOnGameFinishedListener(this::finishGame);
    } // This is where onCreate ends

Add two methods:

private void recordCollectedCircle() {
    gameProgress.recordCollectedCircle();
    showProgress();
}

private void showProgress() {
    // getString loads the text format circles_balance_format from strings.xml.
    // The second argument replaces %1$d in the pattern with the numerical value of the balance.
    // The prepared text is passed to setText to update the TextView.
    binding.circlesBalanceText.setText(getString(
            R.string.circles_balance_format,
            gameProgress.getCirclesBalance()
    ));

    // in the same way show the total number of circles collected.
    binding.lifetimeCirclesText.setText(getString(
            R.string.lifetime_circles_format,
            gameProgress.getLifetimeCircles()
    ));

    // and finally show the number of Pushers purchased.
    binding.pushersText.setText(getString(
            R.string.pushers_format,
            gameProgress.getPusherCount()
    ));
}

In Circles: %1$d, %1$ selects the first argument after the string resource ID, and d formats it as a decimal integer. Thus, getString(R.string.circles_balance_format, 7) returns Circles: 7. Keeping the format in strings.xml separates text from code and allows translation without changing MainActivity.

The Activity does not calculate the new values itself. It asks GameProgress to update the state, then displays that updated state.

5. Running and testing

Run the application and check:

  1. Open the application and make sure that Circles: 0, Lifetime: 0 and Pushers: 0 appear.
  2. Start a game and put one circle in the target. Both circle counters should become 1 immediately.
  3. Finish the game. The counters should show 5, and the completion dialog should appear as usual.
  4. Start another game and collect a circle. Both the counters should show 6.
  5. Close the application completely and open it again. The values 6 and 0 need to be reloaded.
  6. Check that the Local notification and Invite buttons still work.

The game now has two measures of success: a good time in a short round and progress that accumulates over time. In the next chapter we will add an autonomous mode where circles continue to appear without ending the game.

Continue to Chapter 9: Autonomous Game Mode

Gemini tutor

Open Gem: CollectCircles Idle Lab to get hints, diagnostic questions and explanations appropriate to the stage you are on.