1. The main loop and why it matters
When you write a game, there’s almost always a piece of code responsible for the program’s “heartbeat”: a repeating step “asked → processed → answered → moved on”. In our game, that’s the player’s attempts. If this loop is written carefully, the game feels fair and predictable; if it’s written poorly, you get an immortal player, infinite attempts, a victory “in 8 attempts out of 7”, and other plot twists that were never in the script.
From a program-structure point of view, we keep this order: first set up the settings, then generate the secret, then run the loop, and after the loop print the outcome. This makes main() readable like a script.
Mini outline (without details for now):
fun main() {
// 1) settings
// 2) secret
// 3) attempts loop
// 4) outcome
}
Psychologically, it’s useful to remember: the loop is where off-by-one errors are born most often. So today we’ll talk a lot not only about “how to write it”, but also “where to put attempt++ so you don’t regret it later”.
2. Attempts counter: attempt = 1 and the condition attempt <= maxAttempts
You can write an attempts loop in different ways, but for a beginner the most straightforward option is an explicit attempt counter. We start with attempt = 1 because people are used to counting “first attempt”, not “zero-th attempt”. Then we repeat attempts while attempt <= maxAttempts. This condition is easy to read and usually matches how the rules are stated.
In Kotlin, a while loop works like this: first we check the condition, then execute the body, then check the condition again. That’s the basic behavior of while, and it’s exactly what we need for “while attempts remain — keep playing”.
Mini example: “loop skeleton” without the game:
fun main() {
val maxAttempts = 3
var attempt = 1
while (attempt <= maxAttempts) {
println("Attempt $attempt out of $maxAttempts")
attempt++
}
// Attempt 1 out of 3
// Attempt 2 out of 3
// Attempt 3 out of 3
}
Note: attempt++ is at the end of the iteration. This looks natural, but in a real game we’ll have continue and break branches — and that’s when the increment’s position becomes critical.
Table: “<= or <”
Sometimes a beginner writes attempt < maxAttempts and unexpectedly gets one fewer attempt. To avoid guessing, it helps to keep this small mapping in mind:
| What we want in plain language | Familiar math | Loop condition with attempt = 1 |
|---|---|---|
| “exactly N attempts” | |
|
| “while the attempt is less than N” | |
|
3. Robust input: Int?, null, and the rule about attempts
User input is like the weather: you can hope for sunshine, but it’s better to have an umbrella. In our game, the “umbrella” is toIntOrNull() and the contract of readGuessOrNull(min, max): Int?. That is, we don’t crash with an exception; we get null if the input is not a number or the number is out of range. This was done in the previous lecture, and today it’s important to integrate this function into the loop correctly.
And then a game-design question appears: does invalid input count as an attempt or not? There’s no “right/wrong” choice here — it’s a convention. The key is to pick one and express it in code in a way that can’t be accidentally broken.
Option A: invalid input does NOT spend an attempt
In this option, if the user enters "abc" or 9999, we print “Invalid input” and ask again without increasing attempt.
The key trick here is continue. It stops the current iteration and moves on to the next condition check. Kotlin supports continue and break as standard loop-control operators.
Example (part of the loop):
val guess = readGuessOrNull(min, max)
if (guess == null) {
println("Invalid input. Please try again.")
continue
}
Notice the “magic” (simple, honest, no dark rituals): after continue, the code below doesn’t run, so we never compare null with the secret and never print “too high” for a non-existent number.
Option B: invalid input spends an attempt
Sometimes you want strict rules: entered nonsense — an attempt is gone. Then in the guess == null branch we do attempt++ and only then continue.
Mini example:
val guess = readGuessOrNull(min, max)
if (guess == null) {
println("Invalid input. Attempt lost.")
attempt++ // important: the attempt is burned exactly here
continue
}
A very common logical mistake is to forget attempt++ in this option and end up with an infinite loop on invalid input. The game becomes “eternal”: you can enter “cat” forever and the attempts never end. Sometimes that’s cute. Usually it’s a bug.
4. Comparing the guess and the secret: hint, win, and break
After we get a valid guess guess: Int, the fun part starts: compare it to the secret and print a hint. In the previous lecture, we already prepared the functions:
- compareGuess(guess, secret): Int, returns -1, 0, or 1;
- hintText(result): String, returns “Too low/Too high/You got it!”.
Today it’s important to integrate this so that:
1) the hint is always printed for valid input;
2) a win ends the loop immediately;
3) the attempts counter increases only when needed.
Mini snippet:
val result = compareGuess(guess, secret)
println(hintText(result))
if (result == 0) {
isWon = true
break
}
break exits the loop instantly. Kotlin guarantees this behavior for break/continue in loops.
There’s a subtlety you’ll feel in practice: if you do attempt++ first and only then check for a win, the win can end up being “one attempt later” in the final message. That’s why the win is usually recorded before incrementing the counter.
5. Where to place attempt++: the main source of off-by-one bugs
The attempts counter is a small variable with a big personality. It constantly tries to “drift” the wrong way: increment one extra time, fail to increment in one of the branches, increment before checking a win. So now we’ll deliberately go over the “correct geometry” of attempt++ for the most popular option: invalid input does not spend an attempt.
The idea is simple: an attempt is considered spent when we had valid input, compared it to the secret, and it was not a win. So it makes sense to put attempt++ at the end of a successful (non-winning) iteration.
Mini example: “the correct place”:
// guess != null, compared, did not guess
attempt++
And here’s an example of “almost correct, but it will hurt later”:
attempt++ // incremented in advance
val guess = readGuessOrNull(min, max)
Why does it hurt? Because if the input is invalid and we do continue, the attempt has already been incremented, even though under the chosen policy it should not have been. That is, you accidentally switched to the policy “invalid input spends an attempt”, without declaring it.
6. Final result after the loop: win or loss without confusion
After the loop, beginners often feel tempted to think: “well, if the loop ended, that means I lost”. But the loop can also end due to a win (break). So we need an explicit win indicator, for example var isWon = false.
The final block should be simple: if you won — congratulate; otherwise — reveal the secret. And here it’s important not to confuse which attempt counts as “the number of attempts”. If attempt is incremented only after failed attempts, then on a win attempt points to the number of the winning attempt — and that’s convenient.
Example final output:
if (isWon) {
println("Victory! You guessed it in $attempt attempts.")
} else {
println("No attempts left. The secret number was: $secret")
}
If in your version you increment attempt somewhere else (for example, at the start of the loop), you’ll also have to restructure the outcome. That’s normal programmer life: code is a contract, and if you change a clause, the consequences change.
7. Assembling into a single main()
Now we’ll assemble everything into one program. I’ll intentionally keep the code chunks small and “glueable” so you can move them step by step rather than copy a huge sheet.
Settings and starting the game
This block usually goes first:
import kotlin.random.Random
fun main() {
val min = 1
val max = 100
val maxAttempts = 7
val secret = generateSecret(min, max)
var attempt = 1
var isWon = false
println("I picked a number from $min to $max. You have $maxAttempts attempts.")
}
Here generateSecret(min, max) is a function from the previous lecture (with require(min <= max) and Random.nextInt(from, until)).
Main loop
This is the central fragment. Pay attention to the order: attempt status → input → null handling → comparison → hint → win → attempt increment.
while (attempt <= maxAttempts) {
println("Attempt $attempt out of $maxAttempts")
val guess = readGuessOrNull(min, max)
if (guess == null) {
println("Invalid input. Enter an integer within the range.")
continue
}
val result = compareGuess(guess, secret)
println(hintText(result))
if (result == 0) {
isWon = true
break
}
attempt++
}
In this code, continue keeps the structure clean: if there is no valid input, we don’t fall through and we don’t create nested ifs. This use of continue is one of the few cases where it genuinely improves readability.
Game result
After the loop — only the outcome:
if (isWon) {
println("Victory! You guessed the number in $attempt attempts.")
} else {
println("You lost. The secret number was: $secret")
}
If invalid input should spend an attempt
Sometimes it’s useful to see that a “policy” is not philosophy, but two specific lines of code. The whole game stays the same; only the guess == null branch changes: now we increment the attempt and continue.
Here’s the fragment that differs:
val guess = readGuessOrNull(min, max)
if (guess == null) {
println("Invalid input. Attempt lost.")
attempt++
continue
}
Key point: you don’t need to rewrite the whole loop when you change the rule. It’s good when the rule is localized in one place — that’s “maintainability”, without big words.
Execution-flow diagram
When you’re learning to program, your brain often does this: it reads code and tries to imagine all transitions “like a movie”. That works for 5 lines, but starts to struggle at 30. So it’s useful to draw a diagram once, and then refer back to it when something doesn’t work.
flowchart TD
A[Game start: settings, secret, attempt=1] --> B{attempt <= maxAttempts?}
B -- no --> Z[Print the result: win/loss]
B -- yes --> C["Print 'Attempt attempt out of maxAttempts'"]
C --> D["readGuessOrNull(min, max)"]
D --> E{guess == null?}
E -- yes --> F[Input error message]
F --> B
E -- no --> G["compareGuess(guess, secret)"]
G --> H["Print hintText(result)"]
H --> I{result == 0?}
I -- yes --> J[isWon = true; break]
J --> Z
I -- no --> K[attempt++]
K --> B
If you change the policy to “invalid input spends an attempt”, only block F changes: it gets an attempt++ before returning to the condition check.
8. Typical mistakes in the main attempts loop
Mistake #1: attempt++ is not placed where the attempt is actually “spent”.
The most common bug looks like this: you incremented attempt at the beginning of the iteration, then got invalid input and did continue. As a result, the attempt was “burned”, even though by your rules it shouldn’t have been. And vice versa: you wanted invalid input to spend an attempt, but forgot to increment attempt in that branch — and got an infinite loop on “bad” strings.
Mistake #2: the win is checked after incrementing, and the final attempt becomes 1 higher.
If you do attempt++ first and only then discover a win, the outcome “Victory in 4 attempts” can easily turn into “in 5”, even though you actually won on the 4th. The fix is simple: record the win before incrementing and exit the loop immediately via break.
Mistake #3: null handling is missing, and the comparison logic tries to work with a non-existent number.
Sometimes this shows up not as a crash, but as a weird architecture: a student starts inventing a magic value like -999 to represent an input error. It’s much cleaner to use Int? and an explicit guess == null check, and then continue, so the rest of the code works only with valid data.
Mistake #4: the loop ends, and the program always prints “loss”, even if there was a break.
This happens when the final output depends only on the fact that you exited the loop, not on why you exited. If you exit due to a win via break, the loop ends too. That’s why you need a flag like isWon (or another explicit state) to print the result.
Mistake #5: inconsistent range boundaries.
Visually this looks like “the game hates me”: you enter max and it says “out of range”, or you can’t guess the number because the secret is generated, for example, as 1..99, while you think it’s 1..100. The solution is simple: min/max must be single shared settings, and both secret generation and input validation must rely on them.
Mistake #6: continue is used in a way that makes the code harder to read.
continue is good when it helps you “exit early” from an iteration on bad input and keeps the main path straight. But if continue appears in five places, it becomes hard to read: your eyes keep jumping around. In our game, one continue in the input-error branch is usually enough.
GO TO FULL VERSION