JavaRush /Courses /Kotlin SELF /Behavior scenarios and debugging: finding mistakes in att...

Behavior scenarios and debugging: finding mistakes in attempts and branching

Kotlin SELF
Level 15, Lesson 4
Available

1. Behavior scenario: what it is and why you need it

When a program is small, it’s very tempting to test it like this: run it, enter a couple of numbers, feel happy that it “kinda works,” and go make some tea. The problem is that “kinda” is a technical term meaning “it will break in 15 minutes, just not right now.” A behavior scenario is an attempt to make a run predictable: you define the situation in advance and you know in advance what the program must do.

A scenario is not about automated tests and not about “super engineering” (we’re not touching those today). It’s about the habit of thinking like: “If the user enters something that isn’t a number—what happens?”, “If they enter the minimum boundary of the range—what happens?”, “If attempts run out—what happens?”. Essentially, a scenario is a small contract between you and your program: you promise to provide a specific input, and the program must produce a specific outcome.

To make it easier to keep in mind, you can think in the style of a mini-story: “Given → When → Then.” Given: a fixed secret, 3 attempts, range 1..10. When: the user enters abc, then 11, then 7. Then: the program complains about input twice and only on the third try compares the number with the secret. (And yes, even here an important question pops up: “Does invalid input consume an attempt or not?”—you decided that earlier, and now you verify it.)

2. Predictability and debug logs

Fix the secret and disable “random”

Any debugging suffers when the outcome changes on its own. Random is a great tool for a game, but an awful companion for checking logic: today the secret is 42, tomorrow it’s 17, and you’re trying to understand why on the third attempt the program suddenly lost. So the first debugging technique is this: during checks, make the secret fixed so you can reproduce behavior again and again.

The simplest option is to introduce a debug flag and pick the secret based on it. This is the “honest” approach: in normal mode the game stays a game; in debug mode it becomes a lab mouse (in a good way).

val debug = true

val secret = if (debug) {
    42
} else {
    generateSecret(min, max)
}

println("Game started!") // Game started!

Notice an important psychological point: you’re not “breaking the game,” you’re temporarily changing the source of the secret for the sake of reproducibility. It’s like mounting a bicycle onto a stand to adjust the brakes: you can’t ride like that, but repairing is very convenient.

Another handy technique is to print a hint that we’re in debug mode, so later you don’t sit in confusion wondering why “I always guess in one try, I’m a genius” (it’s because you wrote the secret yourself).

if (debug) {
    println("DEBUG: secret fixed = $secret") // DEBUG: secret fixed = 42
}

Just please don’t forget to turn debug off afterward. A game that honestly prints “secret = 42” isn’t a game—it’s a spoiler simulator.

Debug messages: where to place println("DEBUG: ...")

A debug println is as ancient as the first bugs (that is, very ancient). It’s good if you place it surgically and print what actually helps. It’s bad if you start printing everything and turn the console into a waterfall of text where the most important details get lost.

The main idea: at each “game step” you usually care about three or four values: the attempt number, the entered guess, the secret, and the comparison result (for example -1/0/1). So it makes sense to create a small debug-output function—so you don’t repeat the same line, and so you can disable all debug with a single flag.

fun debugState(debug: Boolean, attempt: Int, secret: Int, guess: Int?) {
    if (!debug) return
    println("DEBUG: attempt=$attempt secret=$secret guess=$guess")
    // DEBUG: attempt=1 secret=42 guess=10
}

And use it in the loop carefully, right after input (when guess can be null)—this way you see what actually came from the keyboard.

val guess = readGuessOrNull(min, max)
debugState(debug, attempt, secret, guess)

if (guess == null) {
    println("Invalid input")
    continue
}

A slightly “smarter” option is to also print compareResult. This is especially useful when hints seem “swapped”: for example, you enter a number larger than the secret, but the program prints “Too low.” Often the issue is in the comparison or in branches being swapped.

val result = compareGuess(guess, secret)
if (debug) {
    println("DEBUG: compareResult=$result") // DEBUG: compareResult=1
}
println(hintText(result))

3. Scenario table: win, lose, input, and boundaries

When scenarios live only in your head, they quickly turn into mush: “I think I already checked that… or was that in a past life?”. So it’s useful to formalize scenarios at least as a table: short, no novels, but in a way that lets you repeat the check.

Below is an example table for our game. Here I deliberately include “unpleasant” cases (not a number, spaces, out of range), because those are what most often break the program.

Scenario Settings User input (step by step) Expected behavior What we verify
Immediate win secret=42, maxAttempts=7 42 prints “Guessed!” + game ends correct termination on win
Not an immediate win secret=42, maxAttempts=7 10, 50, 42 “Too low”, “Too high”, “Guessed!” comparison and hints
Loss secret=42, maxAttempts=3 1, 2, 3 three hints + final “you lost” attempt limit and final message
Not a number secret=42, maxAttempts=7 abc, then 42 input error message, then normal gameplay toIntOrNull() and handling null
Empty string/spaces secret=42, maxAttempts=7 "   ", then 42 input error (after trim() the string is empty) normalization via trim()
Out of range range 1..100, secret=42 101, then 42 “out of range” error, then normal gameplay check guess in min..max
Minimum boundary range 1..100, secret=1 1 win boundary correctness
Maximum boundary range 1..100, secret=100 100 win upper boundary correctness

Notice: even without automation, this table already gives you structure. You didn’t just “poke numbers,” you went through a set of required situations. If a bug appears later, you’ll be able to say not “something broke,” but “the ‘out of range’ scenario broke”—and that’s already very close to how developers think in real teams.

4. Game logic: attempts, boundaries, and branching

Off-by-one errors: attempts, <=, and where attempt++ belongs

Most logical bugs in “guess the number” games are not mystical problems, but plain old off-by-one errors. They show up where you count attempts or work with range boundaries. And they’re nasty because the program almost works: sometimes it loses one attempt too early, sometimes it says “win in 4 attempts” when there were 3 attempts, sometimes it “gets stuck” on the same attempt when the input is invalid.

The most common risk zone is where attempt++ is placed. If invalid input does not consume an attempt, then the increment should happen only when an attempt actually “counted,” meaning you have a valid number and you compared it to the secret.

Here is a typical fragment with the correct placement of attempt++ (policy: invalid input does not consume an attempt):

val guess = readGuessOrNull(min, max)
if (guess == null) {
    println("Invalid input")
    continue
}

val result = compareGuess(guess, secret)
println(hintText(result))

if (result == 0) break

attempt++ // the attempt is consumed only here

Now imagine a typical bug: attempt++ is at the beginning of the loop or right after printing the attempt number. Then invalid input starts “burning” attempts (maybe you didn’t want that), and sometimes you get a win “with one extra attempt,” because the counter increased even before checking for victory.

Another subtlety is the loop condition. For example, if attempts are counted from 1 to maxAttempts, then the “logical” check looks like attempt <= maxAttempts. If you accidentally write attempt < maxAttempts, you cut off the last attempt and lose one step earlier. Seems small, but it’s exactly these small things that make up “why the user is unhappy.”

If you want to quickly verify whether the attempt boundary works correctly, use the “lose with 1 attempt” scenario: set maxAttempts = 1, fix the secret, enter an incorrect number, and see what happens. With such small settings, off-by-one errors jump out almost instantly.

Checking comparison and when: where hints break

When hints work incorrectly, the culprit is usually not Kotlin, but our logic. But Kotlin can help by forcing you to write branching more explicitly. In our game there are two key places: the comparison function compareGuess and the text function hintText. They must be consistent: if compareGuess returns -1 for “less than the secret,” then hintText(-1) should say “Too low,” not “Too high” (otherwise the game turns into a psychological thriller).

For hint text, it’s convenient to use when as an expression: it’s compact and readable.

fun hintText(compareResult: Int): String {
    return when (compareResult) {
        -1 -> "Too low"
        1 -> "Too high"
        else -> "Guessed!"
    }
}

To check such functions “separately from the game,” it’s useful to do a tiny manual check: call compareGuess and hintText on known values in advance. These are not unit tests, just a small self-check you can temporarily enable in main.

val secret = 42
println(hintText(compareGuess(10, secret))) // Too low
println(hintText(compareGuess(99, secret))) // Too high
println(hintText(compareGuess(42, secret))) // Guessed!

If everything is correct here, but in the game the hints “dance,” then the problem is not in hintText, but in the fact that you’re comparing the wrong values (for example, you overwrote secret somewhere, or handled guess incorrectly, or the attempt increments in the wrong place and you’re looking at “the wrong iteration”).

5. Invariants and safety checks: require and check

An invariant is a rule that must remain true during program execution. In our game, typical invariants are simple: the range must be valid (min <= max), the secret must be within the range, the attempt number must not become negative, and the comparison function must return only -1, 0, or 1. In real life, invariants save you from the state of “the game behaves weirdly but doesn’t crash, and that’s even worse.”

Checks require and check are a way to say in code: “if this is not true, then we are in the wrong world.” We already used require when generating the secret, and it’s a great example: it’s better to fail fast with a clear message than to get a secret from a “mixed-up range” and then spend hours chasing ghosts.

fun generateSecret(min: Int, max: Int): Int {
    require(min <= max) { "min must be <= max (min=$min, max=$max)" }
    return kotlin.random.Random.nextInt(from = min, until = max + 1)
}

And here is an example of a “safety guard” for compareGuess, if you’re afraid someone might change the code and start returning weird values. This is not required for a learning project, but as a debugging technique, it’s useful.

val result = compareGuess(guess, secret)
check(result == -1 || result == 0 || result == 1) {
    "compareGuess() returned an impossible result: $result"
}
println(hintText(result))

The point of such checks is not to “punish the user,” but to catch a bug as close as possible to where it was born. The farther a bug has managed to “run away,” the harder it is to reconstruct where it appeared.

6. Mini-debug in IntelliJ IDEA: loop, breakpoint, and values

When println("DEBUG...") no longer helps (or helps, but you’re tired of manually reading a wall of text), it’s time to remember the basic debugger. We’ve already met it earlier, and now we only need the simplest actions: set a breakpoint, step line by line, and inspect variable values. It’s like “slow-motion footage” of your program: you see at what moment attempt became “wrong,” exactly where continue triggered, and why the if branch didn’t go where you expected.

The most convenient stopping point in this game is inside the attempt loop, right after input and right before comparison. Then in one place you see attempt, secret, guess, and you can also see why guess ended up null (for example, you entered spaces, trim() produced an empty string, toIntOrNull() returned null—and that’s normal). If you set the breakpoint too early, you won’t see interesting values yet; if too late, you’ll miss the moment when everything went wrong.

And one more nuance: the debugger is especially good at catching off-by-one errors, because you literally see attempt change. If you expected that after invalid input the attempt would not increase, but it did—you immediately understand which line caused it.

7. Common mistakes with scenarios and debugging

Error #1: testing “by luck” instead of with scenarios.
If you run the game differently each time and hope that “it will definitely show up now,” the bug can disappear or appear randomly, especially when the game uses Random. It’s much more effective to fix the secret and run the same scenario multiple times until it’s clear where the logic diverges from expectations.

Error #2: not fixing the attempt policy for invalid input.
Very often the program “gets stuck” on one attempt or, on the contrary, burns attempts too quickly because the developer didn’t decide: does invalid input consume an attempt or not. As a result attempt++ ends up in one branch, then another, then is forgotten next to continue, and behavior becomes unpredictable.

Error #3: placing attempt++ in the wrong spot and getting “win in N+1 attempts.”
If you increment attempt before checking for victory, you can end up with a nice message like “Win in 8 attempts!” when there were 7 attempts (and the user might notice). The rule is simple: first handle input, compare, check for win; only then increment the counter if the game continues.

Error #4: printing too much debug text and losing track of what’s happening.
Debug messages should answer a specific question. If you print 20 lines per iteration, your brain stops looking for patterns and starts scrolling like a social feed. It’s better to print 1–2 lines with key variables (attempt, guess, secret, result) and be able to disable debug with a single flag.

Error #5: swapping comparison branches (< and >) and “fixing” the wrong place.
A classic situation: hints are always “wrong.” A beginner developer starts editing hintText, then readGuessOrNull, then accidentally breaks input… while the problem was in one place: the conditions guess < secret and guess > secret are swapped. A check with a fixed secret and a couple of preselected values (10, 99, 42) usually reveals this in a minute.

Error #6: leaving a “spoiler” (printing the secret) in normal game mode.
During debugging, printing the secret is useful. After debugging, it turns the game into a joke (and not always a funny one). So debug mode must be explicitly switchable, and lines like println(secret) should live only under if (debug) and disappear from the final version.

1
Task
Kotlin SELF, level 15, lesson 4
Locked
Debug mode
Debug mode
1
Task
Kotlin SELF, level 15, lesson 4
Locked
Hint and log
Hint and log
1
Task
Kotlin SELF, level 15, lesson 4
Locked
Pattern-based input
Pattern-based input
1
Task
Kotlin SELF, level 15, lesson 4
Locked
Three attempts
Three attempts
1
Survey/quiz
Mini project, 15 level, 4 lesson
Unavailable
Mini project
Mini project
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION