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
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
- 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. - 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.
- Sync: Allow Gradle to download the pinned dependencies. Initial setup requires internet. The app runs offline and needs no backend, login or API key.
- Run: In Device Manager create an emulator with API 23 or newer, or connect a device with USB debugging. Select the
apprun configuration and press Run. - 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.
- 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.
.\gradlew.bat :app:assembleDebug :app:testDebugUnitTestKeep the toolchain consistent
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.
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.
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.
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.
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.
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
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.
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() {Stop and check
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.
/** 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.CORRECTStop and check
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.
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,Stop and check
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.
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() }),Stop and check
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.
) { 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
},Stop and check
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.
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)) }
}Stop and check
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.
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))
}
}Stop and check
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 here | Responsibility |
|---|---|
| MainActivity.kt | The launcher Activity installs the Compose UI with setContent. |
| CommonUi.kt | Material 3 theme and reusable screen container with scrolling and system insets. |
| Application Kotlin files | State, rules, navigation or persistence introduced in the guide. |
| app/src/main/res | Images, text resources, and the launch theme. |
| app/build.gradle.kts | SDK settings and the exact libraries used by this app. |
| README.md / TOOLCHAIN.md | Student 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.
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 this | Expected 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
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
- Submit the Android Studio project without build caches,
.gradleor machine-specificlocal.properties. - Include two screenshots demonstrating normal behavior and an edge case.
- Add a short README recording the device/API version, actions, observed outcomes, and any known limitation.
- 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?
| Criterion | Points | What to demonstrate |
|---|---|---|
| Core behavior | 40 | All required actions give the expected result. |
| Validation and lifecycle | 20 | Invalid inputs, empty states, rotation and the project's persistence rules work. |
| Interface and accessibility | 20 | Readable contrast, labeled inputs, meaningful image descriptions, scrollable content and usable touch targets. |
| Code and explanation | 20 | Small 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.