JavaRush /Courses /Kotlin SELF /Generating a secret with Random: range boundaries and the...

Generating a secret with Random: range boundaries and the “off-by-one” error

Kotlin SELF
Level 15, Lesson 1
Available

1. Why do we even need Random if we can “just pick 42”

Honestly, “pick 42” is a great plan… until you try to play a second time. In a mini project, it’s important that the game is fair and that each run actually creates a new secret; otherwise the user will quickly notice the pattern (especially if they are a programmer—and programmers can sense patterns in their bones).

In Kotlin, there is a standard mechanism for random numbers: kotlin.random.Random. We’ll use it to generate a secret number within a given range. And here’s where it gets interesting: we usually describe the range “like a human” as 1..100 (including 100), but the generator often works “like a computer”—with an upper bound that is not included. This creates the classic “off-by-one” error, where 100 never shows up—and the game becomes a tiny bit unfair, which is a tiny bit bad for your reputation.

Mini sketch (no boundary details yet):

import kotlin.random.Random

fun main() {
    val min = 1
    val max = 100

    val secret = Random.nextInt(from = min, until = max) // looks suspicious for now!
    println("Secret generated") // Secret generated
}

This compiles and runs… but max may never appear here. In the next section, we’ll see why.

2. from and until: why the upper bound is excluded

Random numbers are one of those areas where the language and the library try to be extremely precise in wording. In Kotlin, Random.nextInt(from, until) has this contract: the number is generated from from inclusive to until exclusive. That is, from is among the possible values, and until is not.

In plain English, that means: “give me any number from the half-open interval [from, until).” And in game terms it means: “if I want the range 1..100, then until must be 101.”

Let’s make a small translator table, because a beginner’s brain (and an experienced developer’s brain on a Friday evening) is equally prone to messing up boundaries.

How we say it How we write it as a Kotlin range What you need for Random.nextInt(from, until)
“from 1 to 100 inclusive”
1..100
from = 1, until = 101
“from 0 to 9 inclusive”
0..9
from = 0, until = 10
“from 10 to 20 inclusive”
10..20
from = 10, until = 21

That’s why the “off-by-one” error is so popular: in a human’s head, it’s often “both ends are included,” but the function’s contract is different. And the function is not at fault: the docs honestly say that until is excluded.

To lock this in, let’s look at an illustration:

We want: 1..5 (including 5)

1   2   3   4   5
^               ^
from            max (inclusive)

But Random.nextInt(from, until) works like this:

from=1, until=6

1   2   3   4   5   6
^                   ^
included            NOT included

That’s why we almost always write until = max + 1 when the game rules say max must be reachable.

3. Correct secret generation for the range min..max

Now let’s assemble the correct formula. We have the game settings:

  • min — the minimum possible secret number,
  • max — the maximum possible secret number,
  • and an agreement: both values are included in the range.

Then secret generation should look like this:

import kotlin.random.Random

fun main() {
    val min = 1
    val max = 100

    val secret = Random.nextInt(from = min, until = max + 1)
    println("Secret generated") // Secret generated
}

Here max + 1 is not “magic,” but a translation from “inclusive” to “until (exclusive).” That’s how we give the generator a chance to produce 100.

One more important point: Random.nextInt(from, until) will throw IllegalArgumentException if from >= until. That is, if you mixed up the boundaries or made the range empty, the program will crash (and that’s fine: it’s a programmer error, not “bad luck”).

That’s why we’re slowly arriving at this idea: it’s better to hide secret generation inside a separate function, where we can centrally add require and avoid smearing checks all over main.

4. The generateSecret(min, max) function: fewer duplicates, more guarantees

As a program grows, any repeated formula becomes a potential bug. Today you wrote max + 1, tomorrow you forget the + 1 somewhere else, and the day after tomorrow you spend three hours “debugging Random,” even though Random has nothing to do with it. So a good habit is to move generation into a function.

Important: we’re not building an architecture yet and we’re not inventing complex types. We’re just making a small function with a clear contract: “give me a number in the range min..max inclusive.”

import kotlin.random.Random

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

This code contains two very “grown-up” ideas.

The first idea is a precondition via require. If someone passes min=100, max=1, we don’t try to “guess” what the author meant; we honestly stop the program with a message. That’s the fail-fast style: better to fail immediately and clearly than to play “guess why it always loses.”

The second idea is that we hide the rule until = max + 1 in one place. Now main can read like a scenario: “set the settings → generate the secret.” And the “why plus one” stays inside the function and doesn’t pollute the main code.

Usage example:

fun main() {
    val min = 1
    val max = 100

    val secret = generateSecret(min, max)
    println("Game started!") // Game started!
}

Notice that we do not print secret. If you print the secret, the game becomes “Guess the number (but we winked at you).” We’ll still use debug output later, but carefully and temporarily.

5. Protection against the “off-by-one” error: a quick boundary check

Off-by-one errors are annoying because the program looks like it works. It doesn’t crash, it honestly accepts input, it prints hints… it’s just that one value can never appear. Users won’t always notice, and you might live for a month with a “slightly unfair” game.

We need to learn how to do a quick self-check: “does my generator actually produce boundary values?” We’re not writing full-blown tests yet (that’s a separate topic), but we can do a simple check in main: generate numbers a few times and see whether min and max show up.

Let’s make a minimal diagnostic snippet. It doesn’t mathematically prove that everything is perfect, but it catches the typical “forgot + 1” mistake really well.

fun main() {
    val min = 1
    val max = 3

    repeat(10) {
        val secret = generateSecret(min, max)
        println(secret) // for example: 2, 1, 3, 2, ...
    }
}

Why did I choose max = 3 instead of 100? Because the smaller the range, the faster you’ll see all options, including the upper bound. If you have a bug and you use until = max, then with min=1, max=3 you’ll only see 1 and 2—and very quickly realize that 3 “disappeared.”

If you want an even more “logical” check, you can temporarily add a flag and print whether the maximum was ever seen. We’re not using collections yet, so we’ll do it the simple way—with boolean variables:

fun main() {
    val min = 1
    val max = 3

    var seenMax = false

    repeat(20) {
        val secret = generateSecret(min, max)
        if (secret == max) seenMax = true
    }

    println("Max occurred: $seenMax") // Max occurred: true (most likely)
}

Again: this is not a scientific paper about probabilities, but a practical way to catch a “shifted boundary” in 10 seconds.

6. Useful nuances: ranges and seed

Random.nextInt(range) — reads simpler, but don’t mix styles

Sometimes you want to write it “like a human” right away: “give me a number from min..max.” In Kotlin, there is an overload that accepts a range (for example, 1..100) and generates a number inclusive of the range boundaries.

It looks very nice:

import kotlin.random.Random

fun main() {
    val min = 1
    val max = 100

    val secret = Random.nextInt(min..max)
    println("Secret generated") // Secret generated
}

So why do we even bother with from/until?

Because discipline in conventions matters in a project. Today you generate the secret via min..max, tomorrow somewhere else you write nextInt(from, until) and accidentally forget +1. You end up with two different “boundary rules” in one program. And when there are two rules, the user guesses the number while the developer guesses why it can’t be guessed.

So for a learning mini project, I recommend choosing one style and sticking with it. At this step we lock in the from/until style, because it trains attention to boundaries better (and it’s very useful later when arrays and indexes appear).

But it’s useful to remember: Random.nextInt(range) exists, it’s readable, and it honestly works “inclusive.”

Seed and repeatable “randomness” for debugging

Randomness is a friend to gameplay, but an enemy of reproducibility. Sometimes you catch a bug that happens “about once every 50 runs,” and you feel like a ghost hunter: you definitely saw it break, but you can’t reproduce it anymore.

For situations like that, there is a generator with a seed. The idea is simple: if two generators are created with the same seed, they will produce the same sequence of random numbers (within the same version of the Kotlin runtime).

Example:

import kotlin.random.Random

fun main() {
    val rng = Random(42)
    println(rng.nextInt(1, 5)) // for example: 4
    println(rng.nextInt(1, 5)) // for example: 1
}

For our “Guess the number” game, we won’t use seed in normal mode (otherwise “randomness” becomes predictable). But it’s useful to know about it: in the next lectures, when we discuss scenarios and debugging, seed can become your “replay mode” to reproduce the same behavior.

7. Common mistakes when generating a secret via Random

Error #1: use Random.nextInt(min, max) and think that max is included.
This is the classic “until boundary” case: Random.nextInt(from, until) generates a number from from inclusive to until exclusive. If you pass until = max, the maximum value will never occur. For the inclusive range min..max, you need to pass until = max + 1.

Error #2: don’t validate the range and then be surprised by a crash.
If from >= until, the standard library has every right to throw IllegalArgumentException. For you as the game author, this means: either you swapped min/max, or you formed until incorrectly. Put require(min <= max) in one place (inside generateSecret) so the issue doesn’t get spread across the project.

Error #3: “magic numbers” like 101 instead of max + 1.
Today 101 seems harmless because “well, we have 1..100.” Tomorrow you change the rules to 1..50 and forget to update 101. The game will start mysteriously generating secrets that cannot be guessed under your own rules. The formula must be built from parameters: until = max + 1, not from “memories of the past.”

Error #4: accidentally spoil the secret via println(secret) and forget to remove it.
The debugging paradox: printing the secret to the console helps the developer a lot, but completely breaks the game for the player. If you do print the secret for verification, do it temporarily and intentionally (for example, behind a debug flag), and then turn it off.

Error #5: mix two boundary approaches (sometimes min..max, sometimes from/until) and end up with two different worlds.
Random.nextInt(min..max) works inclusive of the range boundaries. Random.nextInt(from, until) does not. If you use both styles interchangeably, you will almost certainly make an off-by-one mistake somewhere. In a learning project, it’s better to choose one style and be consistent.

1
Task
Kotlin SELF, level 15, lesson 1
Locked
Coupon digit
Coupon digit
1
Task
Kotlin SELF, level 15, lesson 1
Locked
Custom secret
Custom secret
1
Task
Kotlin SELF, level 15, lesson 1
Locked
Secret generator
Secret generator
1
Task
Kotlin SELF, level 15, lesson 1
Locked
Generator boundary
Generator boundary
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION