Stage 2 · Games, lists & navigationProject 5

Guess the Number

Build a playable guessing game, then use it to understand event handling, validation, recomposition, and saving a small screen state across rotation.

Your assignment: Guess the Number

Build a playable guessing game, then use it to understand event handling, validation, recomposition, and saving a small screen state across rotation.

Beginner · 2–3 hours of building

The download is a complete reference app. Read the brief, predict the result, then implement each step in a separate working copy. Code runs in Android Studio on a device or emulator; the website displays and explains the source.

Before you begin

  • Kotlin functions, Int values, nullable parsing, when expressions, and enums
  • Basic Compose Text, Button, OutlinedTextField, and Modifier usage
  • Projects 1–4 or equivalent practice with input validation and observable state

By the end, you can…

  • Generate one secret number for a game without regenerating it during typing
  • Represent feedback with an enum and keep number rules outside composables
  • Count valid attempts and stop accepting guesses after a win
  • Restore the game, input, attempts, and feedback after a device rotation
  • Run local JVM tests that cover invalid input and range boundaries

Required features

  • Choose a random whole number from 1 through 100 inclusive for each new game.
  • Accept a guess through a labeled text field and Check guess button or the keyboard Done action.
  • Show Too low, Too high, or Correct after a valid guess and display the valid attempt count.
  • Reject blank text, decimals, out-of-range values, and numbers too large for Int without increasing attempts.
  • Disable input and submission after winning. New game must clear the previous round and choose another random value.
  • Use string resources and readable feedback; retain an unfinished game when the device rotates.

Set up the project and run the reference app

  1. Open: Extract the ZIP. In Android Studio choose Open, then select the project folder containing settings.gradle.kts. Open that folder rather than just a Kotlin file.
  2. Prepare: Use Android Studio’s bundled JDK 17 or 21 in Gradle settings. In SDK Manager install Android SDK Platform 36 and any Build Tools requested during sync.
  3. Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
  4. Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the app run configuration and press Run.
  5. Observe: Try the normal and boundary scenarios from Test the app. Record what changes after each action. Use Logcat to investigate a crash instead of changing several files at once.
  6. Practice: Copy the extracted folder to a working folder. Keep the build files, manifest, resources and CommonUi.kt. Rebuild the application files following the steps below. Attempt each task before consulting its reference.
PowerShell — in the project folder
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTest
macOS/Linux: chmod +x gradlew, then ./gradlew :app:assembleDebug :app:testDebugUnitTest. Unit tests run without a device; manual UI checks need a device or emulator.

Keep the toolchain consistent

The projects use the existing course’s pinned toolchain: Kotlin 2.2.21, AGP 8.13.2, Gradle 8.13, and Compose BOM 2025.12.01. The original collection uses lifecycle 2.9.4. Projects 8 and 10 add Navigation 2.9.6; project 10 adds Room 2.8.4. All build files and the Gradle wrapper are included. Open TOOLCHAIN.md for setup details.

Understand the topics before coding

For each topic, connect the Kotlin concept to a visible behavior. Identify the value the screen reads and the event that can change that value.

TOPIC 01

State and recomposition

The screen is a description of the current round. Typing changes input, checking changes feedback and attempts, and a correct answer changes finished. Those observable values cause Compose to update the affected UI. Random generation belongs in the saved state initializer or the New game event, so normal redraws cannot silently replace the target.

TOPIC 02

Small saved values

This game saves Int, Boolean, and String values with rememberSaveable. The feedback enum is stored by its name and reconstructed for display. Rotation should therefore leave the same round visible. This is appropriate for a handful of screen values; it is not a leaderboard database or a promise to keep the game after a deliberate force-stop.

TOPIC 03

Validation before a state change

A number keyboard only helps users enter numbers; pasted content can still be invalid. evaluateGuess trims the text, uses toIntOrNull, and checks 1..100 before comparing it with the secret. The event handler increases attempts only for TOO_LOW, TOO_HIGH, or CORRECT, so rejected input never becomes a gameplay penalty.

TOPIC 04

Pure rules and UI events

GuessRules.kt accepts ordinary Kotlin values and returns a GuessFeedback value. It knows nothing about Text, colors, Android context, or the keyboard. The composable translates that result into state and a localized message. This division lets the same rules be tested without launching an emulator.

TOPIC 05

Feedback and accessible controls

Each action has a visible text label. The field exposes an error state for invalid input, and the feedback Text is a polite live region so assistive technology can announce an updated hint. Winning disables both ways of submitting: the button and the guarded submitGuess function.

Three different lifetimes

Recomposition redraws UI from state. rememberSaveable restores small supported values after Activity recreation; a ViewModel retains screen state during configuration changes. Neither replaces permanent storage. The notes project uses Room for records that must remain after closing and reopening the app.

Build the app step by step

Work in this order. The code panels are focused excerpts from the complete files below. They may begin or end inside a function: use the full source browser when copying a file, including its imports. After every step, run the app and check the stated result.

Step 1. Open the project and map the app entry point

Your task: Download this project, extract it, and open the folder containing settings.gradle.kts in Android Studio. Wait for Gradle sync, select an API 23 or newer emulator, and run app. Open MainActivity.kt and trace onCreate → setContent → MiniTheme → MiniScreen → GuessNumberScreen. Keep the supplied MiniTheme and MiniScreen helpers in the same package.

Open: app/src/main/java/com/mastercoding/miniprojects/guess/MainActivity.kt

How it works: ComponentActivity hosts the Compose screen. MiniTheme supplies Material colors and typography, while MiniScreen supplies a scrollable layout and system insets. GuessNumberScreen owns the game behavior. Keeping the host small helps you identify which part you should change when adding a rule.

MainActivity.kt
import kotlin.random.Random

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MiniTheme {
                MiniScreen(stringResource(R.string.screen_title)) {
                    GuessNumberScreen()
                }
            }
        }
    }
}

@Composable
private fun GuessNumberScreen() {
    // The initializer runs for a new game screen, not on every recomposition.
    var secret by rememberSaveable { mutableIntStateOf(Random.nextInt(1, 101)) }
    var input by rememberSaveable { mutableStateOf("") }
    var attempts by rememberSaveable { mutableIntStateOf(0) }
    var feedbackName by rememberSaveable { mutableStateOf(GuessFeedback.READY.name) }
    var finished by rememberSaveable { mutableStateOf(false) }
    val feedback = GuessFeedback.valueOf(feedbackName)

    fun submitGuess() {
Reference excerpt · lines 27–52. The full file, with imports, is in Complete source files.

Stop and check

The app launches with instructions, a guess field, Check guess, an attempt count, and New game. No permission or network setup is needed.

Step 2. Write and inspect the number rules

Your task: Open GuessRules.kt. Read the GuessFeedback enum, then trace evaluateGuess for inputs "abc", "0", "20", "80", and "50" with a secret of 50. Explain why toIntOrNull must run before comparisons. Implement the same decision order yourself, then compare it with the supplied complete solution.

Open: app/src/main/java/com/mastercoding/miniprojects/guess/GuessRules.kt

How it works: An enum gives each outcome a precise identity instead of making game behavior depend on English messages. The invalid branch handles both failed parsing and values outside the game range. The remaining branches can compare valid integers safely. countsAsAttempt expresses the separate counting policy.

GuessRules.kt

/** Pure rule: invalid input never consumes an attempt. */
fun evaluateGuess(input: String, secret: Int): GuessFeedback {
    require(secret in 1..100)
    val guess = input.trim().toIntOrNull()
    return when {
        guess == null || guess !in 1..100 -> GuessFeedback.INVALID
        guess < secret -> GuessFeedback.TOO_LOW
        guess > secret -> GuessFeedback.TOO_HIGH
        else -> GuessFeedback.CORRECT
    }
}

fun GuessFeedback.countsAsAttempt(): Boolean =
    this == GuessFeedback.TOO_LOW || this == GuessFeedback.TOO_HIGH ||
        this == GuessFeedback.CORRECT
Reference excerpt · lines 4–20. The full file, with imports, is in Complete source files.

Stop and check

The five traces produce INVALID, INVALID, TOO_LOW, TOO_HIGH, and CORRECT. Only the final three outcomes consume an attempt.

Step 3. Create a round that survives rotation

Your task: Locate the saved state variables in GuessNumberScreen. Give each variable a job: secret is the target, input is editable text, attempts is the valid count, feedbackName is the last outcome, and finished locks a won round. Run the app, type a number, rotate the emulator, and verify that typing alone does not start another game.

Open: app/src/main/java/com/mastercoding/miniprojects/guess/MainActivity.kt

How it works: rememberSaveable keeps these small supported values through activity recreation. Random.nextInt(1, 101) uses an exclusive upper bound, which makes 100 a possible target. The initializer is deliberately inside saved state creation; placing a random call directly in the composable body would make typing capable of changing the secret.

MainActivity.kt
private fun GuessNumberScreen() {
    // The initializer runs for a new game screen, not on every recomposition.
    var secret by rememberSaveable { mutableIntStateOf(Random.nextInt(1, 101)) }
    var input by rememberSaveable { mutableStateOf("") }
    var attempts by rememberSaveable { mutableIntStateOf(0) }
    var feedbackName by rememberSaveable { mutableStateOf(GuessFeedback.READY.name) }
    var finished by rememberSaveable { mutableStateOf(false) }
    val feedback = GuessFeedback.valueOf(feedbackName)

    fun submitGuess() {
        if (finished) return
        val outcome = evaluateGuess(input, secret)
        feedbackName = outcome.name
        if (outcome.countsAsAttempt()) attempts += 1
        finished = outcome == GuessFeedback.CORRECT
    }

    Text(stringResource(R.string.instructions))
    OutlinedTextField(
        value = input,
        onValueChange = {
            input = it
            feedbackName = GuessFeedback.READY.name
        },
        label = { Text(stringResource(R.string.guess_label)) },
        isError = feedback == GuessFeedback.INVALID,
Reference excerpt · lines 43–68. The full file, with imports, is in Complete source files.

Stop and check

An unfinished round retains its typed text, hint, and attempt count across rotation. New round creation is the only event that replaces the target.

Step 4. Connect one submission event to two controls

Your task: Read submitGuess and then find every place it is called. Bind the Check guess button and keyboard Done action to that one function. Keep its finished guard. After evaluateGuess returns, save the outcome, increase attempts only when countsAsAttempt is true, and set finished only for CORRECT.

Open: app/src/main/java/com/mastercoding/miniprojects/guess/MainActivity.kt

How it works: The event handler is the transition from one round state to another. Sharing the handler prevents the keyboard path from using different counting rules. The early return is useful even though the visible button is disabled after winning, because correctness should be enforced by behavior as well as appearance.

MainActivity.kt
    val feedback = GuessFeedback.valueOf(feedbackName)

    fun submitGuess() {
        if (finished) return
        val outcome = evaluateGuess(input, secret)
        feedbackName = outcome.name
        if (outcome.countsAsAttempt()) attempts += 1
        finished = outcome == GuessFeedback.CORRECT
    }

    Text(stringResource(R.string.instructions))
    OutlinedTextField(
        value = input,
        onValueChange = {
            input = it
            feedbackName = GuessFeedback.READY.name
        },
        label = { Text(stringResource(R.string.guess_label)) },
        isError = feedback == GuessFeedback.INVALID,
        enabled = !finished,
        singleLine = true,
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number,
            imeAction = ImeAction.Done
        ),
        keyboardActions = KeyboardActions(onDone = { submitGuess() }),
Reference excerpt · lines 50–75. The full file, with imports, is in Complete source files.

Stop and check

Both submission paths give identical hints and counts. Rejected input leaves the count unchanged, and a won round cannot gain extra attempts.

Step 5. Render localized feedback from the outcome

Your task: Inspect the TextField label, numeric keyboard configuration, and isError condition. Follow the when expression that chooses a string resource for each feedback enum. Open strings.xml, change one instruction message, and run again. Add a values-ar translation as an optional follow-up without changing the comparison rules.

Open: app/src/main/java/com/mastercoding/miniprojects/guess/MainActivity.kt

How it works: Display text belongs in resources so the same outcome can be explained in different languages. KeyboardType.Number is an input convenience, while the pure validator remains the authority. The live-region modifier makes changed feedback discoverable to screen-reader users without moving keyboard focus.

MainActivity.kt
    ) { Text(stringResource(R.string.check_guess)) }

    val feedbackText = stringResource(
        when (feedback) {
            GuessFeedback.READY -> R.string.feedback_ready
            GuessFeedback.INVALID -> R.string.feedback_invalid
            GuessFeedback.TOO_LOW -> R.string.feedback_low
            GuessFeedback.TOO_HIGH -> R.string.feedback_high
            GuessFeedback.CORRECT -> R.string.feedback_correct
        }
    )
    Text(
        text = feedbackText,
        color = if (feedback == GuessFeedback.INVALID) MaterialTheme.colorScheme.error
            else MaterialTheme.colorScheme.onSurface,
        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }
    )
    Text(stringResource(R.string.attempts, attempts))
    OutlinedButton(
        onClick = {
            secret = Random.nextInt(1, 101)
            input = ""
            attempts = 0
            feedbackName = GuessFeedback.READY.name
            finished = false
        },
Reference excerpt · lines 82–107. The full file, with imports, is in Complete source files.

Stop and check

Invalid input shows an error message and error styling. Valid feedback stays readable, and changing a resource changes the UI without changing gameplay.

Step 6. Reset every part of the round

Your task: Find New game and list every value it resets. Test it after a wrong guess, after invalid input, and after winning. Confirm that the field is cleared, attempts becomes zero, feedback becomes READY, and the field and button are enabled again. A newly generated number is allowed to equal the previous random value.

Open: app/src/main/java/com/mastercoding/miniprojects/guess/MainActivity.kt

How it works: Reset is a complete state transition, not just a text-field clear. Leaving finished or feedback behind would produce a confusing new round. Resetting all related values together makes the game behavior predictable for the player.

MainActivity.kt
    OutlinedButton(
        onClick = {
            secret = Random.nextInt(1, 101)
            input = ""
            attempts = 0
            feedbackName = GuessFeedback.READY.name
            finished = false
        },
        modifier = Modifier.fillMaxWidth()
    ) { Text(stringResource(R.string.new_game)) }
}
Reference excerpt · lines 100–111. The full file, with imports, is in Complete source files.

Stop and check

New game always presents a fresh playable round with zero attempts, regardless of how the previous round ended.

Step 7. Run rule tests, then play strategically

Your task: Open GuessRulesTest.kt and run the test class using its gutter Run icon, or run gradlew.bat :app:testDebugUnitTest on Windows. Read why the test passes a known secret rather than relying on randomness. Then play on the emulator by guessing the midpoint of the remaining range and updating your lower or upper bound after each hint.

Open: app/src/test/java/com/mastercoding/miniprojects/guess/GuessRulesTest.kt

How it works: Deterministic unit tests prove range and comparison behavior without needing to discover the random number. Device checks cover the separate input, rendering, and rotation behavior. A midpoint strategy can locate any target in 1..100 within seven valid guesses when the bounds are updated correctly.

GuessRulesTest.kt

class GuessRulesTest {
    @Test fun invalidGuessesDoNotCount() {
        listOf("", "abc", "0", "101", "1.5", "99999999999999999999").forEach {
            val result = evaluateGuess(it, secret = 50)
            assertEquals(GuessFeedback.INVALID, result)
            assertFalse(result.countsAsAttempt())
        }
    }

    @Test fun validGuessesGiveDirectionalFeedbackAndCount() {
        assertEquals(GuessFeedback.TOO_LOW, evaluateGuess("1", 50))
        assertEquals(GuessFeedback.TOO_HIGH, evaluateGuess("100", 50))
        assertEquals(GuessFeedback.CORRECT, evaluateGuess(" 50 ", 50))
        assertTrue(evaluateGuess("50", 50).countsAsAttempt())
    }

    @Test fun secretCanUseBothEndpoints() {
        assertEquals(GuessFeedback.CORRECT, evaluateGuess("1", 1))
        assertEquals(GuessFeedback.CORRECT, evaluateGuess("100", 100))
    }
}
Reference excerpt · lines 7–29. The full file, with imports, is in Complete source files.

Stop and check

All rule tests pass. You can finish a game without inspecting the secret, and your manual checks confirm that state and feedback stay consistent.

Complete source files

Every text file in the ZIP is available here, including Kotlin, resources, Gradle scripts and the student README. The binary Gradle wrapper JAR is included in the ZIP. Source excerpts and downloads are generated from these same files.

Start hereResponsibility
MainActivity.ktThe launcher Activity installs the Compose UI with setContent.
CommonUi.ktMaterial 3 theme and reusable screen container with scrolling and system insets.
Application Kotlin filesState, rules, navigation or persistence introduced in the guide.
app/src/main/resImages, text resources, and the launch theme.
app/build.gradle.ktsSDK settings and the exact libraries used by this app.
README.md / TOOLCHAIN.mdStudent instructions and setup, usable without the website.

Android launcher Activity, setContent entry point and screen composition. Start tracing the app here.

20 complete text files. Copy the selected file with the code panel button, or download the full project above.

MainActivity.kt
package com.mastercoding.miniprojects.guess

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import kotlin.random.Random

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MiniTheme {
                MiniScreen(stringResource(R.string.screen_title)) {
                    GuessNumberScreen()
                }
            }
        }
    }
}

@Composable
private fun GuessNumberScreen() {
    // The initializer runs for a new game screen, not on every recomposition.
    var secret by rememberSaveable { mutableIntStateOf(Random.nextInt(1, 101)) }
    var input by rememberSaveable { mutableStateOf("") }
    var attempts by rememberSaveable { mutableIntStateOf(0) }
    var feedbackName by rememberSaveable { mutableStateOf(GuessFeedback.READY.name) }
    var finished by rememberSaveable { mutableStateOf(false) }
    val feedback = GuessFeedback.valueOf(feedbackName)

    fun submitGuess() {
        if (finished) return
        val outcome = evaluateGuess(input, secret)
        feedbackName = outcome.name
        if (outcome.countsAsAttempt()) attempts += 1
        finished = outcome == GuessFeedback.CORRECT
    }

    Text(stringResource(R.string.instructions))
    OutlinedTextField(
        value = input,
        onValueChange = {
            input = it
            feedbackName = GuessFeedback.READY.name
        },
        label = { Text(stringResource(R.string.guess_label)) },
        isError = feedback == GuessFeedback.INVALID,
        enabled = !finished,
        singleLine = true,
        keyboardOptions = KeyboardOptions(
            keyboardType = KeyboardType.Number,
            imeAction = ImeAction.Done
        ),
        keyboardActions = KeyboardActions(onDone = { submitGuess() }),
        modifier = Modifier.fillMaxWidth()
    )
    Button(
        onClick = { submitGuess() },
        enabled = !finished,
        modifier = Modifier.fillMaxWidth()
    ) { Text(stringResource(R.string.check_guess)) }

    val feedbackText = stringResource(
        when (feedback) {
            GuessFeedback.READY -> R.string.feedback_ready
            GuessFeedback.INVALID -> R.string.feedback_invalid
            GuessFeedback.TOO_LOW -> R.string.feedback_low
            GuessFeedback.TOO_HIGH -> R.string.feedback_high
            GuessFeedback.CORRECT -> R.string.feedback_correct
        }
    )
    Text(
        text = feedbackText,
        color = if (feedback == GuessFeedback.INVALID) MaterialTheme.colorScheme.error
            else MaterialTheme.colorScheme.onSurface,
        modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }
    )
    Text(stringResource(R.string.attempts, attempts))
    OutlinedButton(
        onClick = {
            secret = Random.nextInt(1, 101)
            input = ""
            attempts = 0
            feedbackName = GuessFeedback.READY.name
            finished = false
        },
        modifier = Modifier.fillMaxWidth()
    ) { Text(stringResource(R.string.new_game)) }
}

Test the app with concrete scenarios

Run these scenarios on your device or emulator. Write down the actual result beside the expected result. A successful Gradle build checks compilation; it does not demonstrate tapping, accessibility, keyboard behavior or rotation.

Do thisExpected result
Submit blank text, pasted abc, 0, 101, 1.5, and an extremely large number.Each input is rejected with a useful message and no increase in valid attempts.
Submit a valid value during an unfinished game.The app gives a directional hint or Correct and increases attempts by exactly one.
Submit with the keyboard Done action and with Check guess.Both routes use the same validation and counting rules.
Make a nonwinning valid guess, type another number, and rotate the emulator.The same secret is retained; input, attempt count, and current feedback state are restored.
Find the target, then try submitting again.The field and button are disabled; the count remains unchanged.
Tap New game after a win or a validation error.The field clears, count becomes zero, initial feedback returns, and guessing is enabled.
Run GuessRulesTest.Invalid strings, comparison outcomes, and endpoint targets all pass without an emulator.

Your completion checklist

Project acceptance checklist0 / 6 verified

Check an item only after demonstrating it. Progress is saved in this browser.

Troubleshooting: understand the cause

A previously useful hint stops making sense after typing.

Cause: The random target is generated directly during composition.

Repair: Create the target in rememberSaveable and regenerate it only inside New game.

Entering abc crashes the app or increases attempts.

Cause: Input uses toInt() or increments attempts before checking validity.

Repair: Use toIntOrNull, validate the range, and count only the three valid outcomes.

A new game remains locked after a win.

Cause: The reset handler clears input but leaves finished true.

Repair: Reset finished, feedback, attempts, and input together with the target.

Rotation loses the round.

Cause: The screen uses plain local variables or only remember for values that must be restored.

Repair: Store the small round values with rememberSaveable and retest activity recreation.

Submit your app and explain your decisions

  1. Submit the Android Studio project without build caches, .gradle or machine-specific local.properties.
  2. Include two screenshots demonstrating normal behavior and an edge case.
  3. Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
  4. Answer the questions below using function and state names from your own implementation.
  • Why would generating a random value in the composable body break this game even when no explicit restart happened?
  • Why should invalid input leave attempts unchanged, and where is that policy expressed in the source?
  • What would you need to change if a best score had to survive an app force-stop or a device restart?
CriterionPointsWhat to demonstrate
Core behavior40All required actions give the expected result.
Validation and lifecycle20Invalid inputs, empty states, rotation and the project's persistence rules work.
Interface and accessibility20Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets.
Code and explanation20Small named functions, clear state ownership and a correct explanation of one event-to-result trace.

Extra challenges after the core works

  • Add Easy (1–20), Normal (1–100), and Hard (1–1000) modes. Update the random range, validator, and displayed labels from one difficulty model.
  • Add a maximum-attempt loss state and reveal the target only after the game ends.
  • Record a best score per difficulty. First discuss which values belong in saved UI state and which need permanent storage.

Choose one extension, write its acceptance check first, then implement it without breaking the original scenarios.

Further reading