1. Why rules matter in a game
When we write training examples in 5 lines, the brain calmly keeps everything at once: “here’s a variable, here’s a condition.” But as soon as we assemble a complete game, a different kind of magic begins: if the rules aren’t fixed, the code turns into a set of “well, here it seems like this,” “and here just in case, otherwise.” So today we’re not making a “game,” but its clear model: what counts as an attempt, when the game ends, what settings we have, and what exactly changes during the game.
Let’s be honest: the “Guess the Number” game seems simple right up to the moment when you try to explain to a computer what an “attempt” and “invalid input” are. A computer is a straightforward creature. If you don’t say that invalid input does not spend an attempt, it will “count” however it ends up (usually incorrectly, but confidently).
So that we don’t treat symptoms later, we’ll start with the most boring (and most useful) part of development: translating requirements into variables and checks.
2. Game rules: formulate requirements
Any program starts with text in human language: “the user does this,” “if this happens — then that.” We must turn this text into rules that cannot be interpreted ambiguously. Today our task is to assemble a minimal set of rules by which the game will be considered fair and predictable: the secret range, the attempt limit, hints, and end conditions. We’re not writing the whole game loop yet; we’re creating a foundation that won’t fall apart.
Basic set of rules
Let’s assume the requirements for the game are:
- The secret number is in the range from min to max (inclusive).
- The player has maxAttempts attempts.
- On each attempt, the player enters a number.
- If the number is less than the secret, print “too small.”
- If it’s greater, “too big.”
- If it’s equal, “guessed!” and the game ends with a win.
- If the attempts run out, the game ends with a loss.
- If the input is invalid (not a number / out of range), we need to decide in advance: does it spend an attempt or not.
Note: the last item is not an “implementation detail,” but a real game rule. In different versions of the game it’s different, and both options are fine. Only one thing is bad: not deciding it in advance.
Invalid input: spend an attempt or not
Because that determines where attempt++ will appear in your loop. And this is a classic place for off-by-one bugs: you seem to have done everything right, but the game suddenly gives one more or one fewer attempt, or gets stuck in a “enter a number” loop.
Today we’ll choose one option and stick to it. For the first version of the game, we’ll use a friendly policy:
Invalid input does NOT spend an attempt.
This is easier for beginners because there are fewer “penalties” for typos, and it’s more convenient to debug.
3. Settings and state: val vs var
When you’re first learning to program, it’s very easy to make everything var. Like, “let it be mutable, maybe it’ll come in handy.” But in projects (even small ones) this leads to chaos: you accidentally changed maxAttempts in the middle of the game, and the logic “honestly broke,” without warnings. So we introduce an important separation: settings and state.
Settings are what define the game scenario and do not change during the game: the secret range, the attempt limit, possibly text messages. State is what changes: the current attempt number, whether you won, the current guess, etc.
In Kotlin, this is very conveniently reflected via val and var.
Mini example: settings and state
val min = 1
val max = 100
val maxAttempts = 7
var attempt = 1
var isWon = false
The meaning of this code is more important than it seems. val here is not a “ban on changing for the sake of banning,” but a way to protect yourself from yourself a week later (or two hours later). And var is an honest admission: “this will change.”
Settings vs state: cheat sheet
| Entity | Example name | val or var | Why |
|---|---|---|---|
| Secret range | |
|
The game rules shouldn’t “drift” |
| Attempt limit | |
|
Otherwise you can “accidentally give a cheat” |
| Current attempt | |
|
Grows with each spent attempt |
| Win/loss | |
|
First you haven’t won, then (possibly) you have |
4. Game state: what to store
The word “state” sounds like something philosophical: “the program’s state of mind.” In practice it’s very down to earth: a set of variables that answer the question “where are we right now?” If you don’t store state explicitly, it becomes “smeared” across conditions and break, and you yourself stop understanding why the game ended.
We’ll start with the minimal state that’s enough for a normal scenario:
- current attempt number;
- win flag (isWon);
- (a bit later) the secret number;
- (inside a loop iteration) the current guess.
Why you need the isWon flag
You could try to define a win like this: “if we exited the loop early, then we won.” Sometimes that works while the game is small. But as soon as additional reasons to exit appear (for example, the user enters the exit command), “early exit” stops meaning a win.
The isWon flag makes the final output unambiguous and readable: if (isWon) ... else ....
Mini example: temporarily fix the secret
Important: in the next lecture, we’ll generate the secret via Random correctly and without off-by-one errors. For now this isn’t about generation, it’s about structure. So for now we’ll fix the secret as a constant—this is also convenient for verifying the logic.
val min = 1
val max = 100
val maxAttempts = 7
val secret = 42 // temporarily fixed for predictability
var attempt = 1
var isWon = false
5. Invariants and checks via require
An invariant is a rule that must be true not “on average,” but always, otherwise the game becomes incorrect. For “Guess the Number,” invariants are simple but extremely useful: if you violate them, the program will behave strangely, and you’ll spend a long time staring at the code with the expression “how is this even possible?”
We’ll introduce a few invariants and decide where exactly to check them to catch errors early.
Examples of invariants
Invariant No. 1: min <= max. If the range is “flipped,” all checks x in min..max will work unexpectedly.
Invariant No. 2: maxAttempts > 0. Otherwise the game is either immediately lost, or the loop doesn’t start at all.
Invariant No. 3: the secret must be inside the range. Today the secret is fixed, but the rule still exists.
Invariant No. 4: attempt must not “fly off” to 0 or negative values. For a human, that’s “obvious”; for a program, it’s only true if you wrote it that way.
Check settings via require(...)
You’ve already seen require in the topic on preconditions and fail-fast: if the input parameters are invalid, it’s better to fail immediately with a clear message than to continue living in an incorrect state.
Let’s make a small function to validate settings:
fun validateGameSettings(min: Int, max: Int, maxAttempts: Int) {
require(min <= max) { "Invalid range: min=$min, max=$max" }
require(maxAttempts > 0) { "maxAttempts must be > 0, currently $maxAttempts" }
}
This function returns nothing (that is, it returns Unit); its job is either to confirm the settings are correct, or to stop the program with a clear reason.
Use the validation in main
fun main() {
val min = 1
val max = 100
val maxAttempts = 7
validateGameSettings(min, max, maxAttempts)
println("Game settings are valid") // Game settings are valid
}
For now this looks like “too much code for one line,” but in a project it pays off very quickly. Errors in settings are the most annoying errors, because they break everything at once.
6. Rules as functions and contracts
When a rule is in one place, it’s usually correct. When you copy it to five places, in one of them there will definitely appear “almost the same thing,” but not quite. So even a simple rule like “a number must be in range” is useful to put into a separate function, so you can read the code like a story: “checked the range → moved on.”
In this section we’ll fix a few convenient “contracts”: about the range, about the comparison result, and about where exactly an attempt “lives.”
The range rule as a function
Let’s make the isInRange function:
fun isInRange(x: Int, min: Int, max: Int): Boolean {
return x in min..max
}
Yes, it’s just one line. But it gives two practical benefits: a single meaning and a single name. Later, when you read the loop code, you’ll see if (!isInRange(...)), and your brain will immediately understand what’s going on, without decoding x in min..max among other conditions.
Three outcomes of a comparison: less / greater / equal
When a human plays, they think in text: “less,” “greater,” “guessed.” When we write a program, we need to represent these three outcomes as something convenient to check and pass between functions. In the next lectures we’ll extract comparison and hints into separate functions, but today we’ll fix the idea itself: the comparison result is always one of three.
Often, a result code is used for this: -1, 0, 1. This isn’t “magic numbers” if we agree on what they mean.
const val TOO_SMALL = -1
const val CORRECT = 0
const val TOO_BIG = 1
Now instead of “why is there a -1 here?” it will be “oh, TOO_SMALL.”
For now, without a full comparison, we’ll just show the idea: by the result code you can select text.
fun hintText(result: Int): String {
return when (result) {
TOO_SMALL -> "Too small"
TOO_BIG -> "Too big"
else -> "Guessed!"
}
}
In this lecture, what matters is not that else catches “guessed,” but that we start thinking in contracts: “the function receives one of three codes and returns clear text.” Contracts are like traffic rules for your functions. Without them, the code starts “crossing the road anywhere.”
An agreement about attempts and attempt++
If there were a world championship for bugs, the mistake “attempt++ is in the wrong place” would confidently make the top 3. The problem is that in a person’s head an “attempt” is an abstract step, but in code an “attempt” must be the exact point where you incremented the counter. If you increment too early, you get a win “in 8 attempts out of 7.” If you don’t increment, you get an infinite loop.
We’ve already chosen the rule: invalid input does not spend an attempt. That means:
- when the input is invalid, we show a message and repeat the same attempt;
- when the input is valid but the guess is wrong, the attempt is spent and we increment attempt.
We’re not writing the full loop yet, but we can already mark the place for attempt++ logically: it goes after a valid attempt ended in failure.
Attempt status text
When you build the main loop, there will already be plenty of logic: input, checking for null, comparison, break/continue. So any small “cosmetic” things are better prepared in advance so the loop has no extra noise. One of those noises is formatting the text “Attempt N of M.”
Let’s make a small function:
fun attemptStatusText(attempt: Int, maxAttempts: Int): String {
return "Attempt $attempt of $maxAttempts"
}
And show how it looks when printed:
val attempt = 1
val maxAttempts = 7
println(attemptStatusText(attempt, maxAttempts)) // Attempt 1 of 7
This isn’t “architecture,” it’s basic care for the reader’s brain. The reader is you in a couple of days.
7. Scenario skeleton: initialization → game → result
A big program is scary not because it’s complex, but because it’s unclear “where the beginning is, where the middle is, where the end is.” So we outline the scenario skeleton right now: what stages will be in main, in what order, and what variables must exist so that each stage is possible.
This structure is like a hiking plan. Even if you don’t yet know how to make a campfire (the attempt loop), you at least know where you’re going and what you’re taking with you (settings and state).
Scenario flowchart
flowchart TD
A[Initsializatsiya nastroek] --> B[Proverka nastroek require]
B --> C[Sozdanie nachal'nogo sostoyaniya]
C --> D[Tsikl popytok: vvod → proverka → podskazka]
D --> E{Ugadal?}
E -->|da| F[Pobeda: pechataem itog]
E -->|net| G{Popytki zakonchilis'?}
G -->|da| H[Proigrysh: pechataem sekret]
G -->|net| D
Today we finish the first three blocks: settings, validation, state. We’ll cover the loop and secret generation in the next lectures.
main scaffold with a temporary secret
fun main() {
val min = 1
val max = 100
val maxAttempts = 7
validateGameSettings(min, max, maxAttempts)
val secret = 42 // fixed for now; generation will be in the next lecture
var attempt = 1
var isWon = false
println("The game has started! I picked a number from $min to $max.") // The game has started! I picked a number from 1 to 100.
}
The game isn’t playing yet, but that’s honest: we’re building the foundation. And it’s important that the variables are already named “by meaning,” not “a, b, n,” otherwise it will be hard to read your own code later.
8. Typical mistakes in this part of the project
Mistake No. 1: making settings var because “it’s more familiar.”
In small examples you don’t notice it, but in a project it leads to nasty bugs: somewhere you accidentally assigned max = 0, and the range became weird. Settings are the game rules, and rules usually don’t change during the match.
Mistake No. 2: not agreeing whether invalid input spends an attempt.
If you don’t decide this in advance, then attempt++ starts appearing “somewhere nearby”: once you increment, another time you forget, a third time you increment twice. As a result the attempt logic spreads out, and you catch off-by-one bugs that are unpleasant to debug.
Mistake No. 3: mixing state and rules in one place.
When variables attempt, isWon, min, max are declared interleaved and without meaning, you lose structure. A good sign of order: first settings (val), then state (var). This is not a mandatory Kotlin language rule, but a very useful rule for your head.
Mistake No. 4: not checking invariants via require and hoping for luck.
If min > max, the game will work “somehow,” and you might spend a long time not understanding why input “in range” doesn’t pass the check. require lets you fail immediately with a clear message and not turn debugging into archaeology.
Mistake No. 5: using “magic numbers” instead of meaningful names.
If your code contains 7, 100, -1, 0, 1 without explanations, then you’ll read the program like a riddle later. Even simple constants like maxAttempts and TOO_SMALL dramatically improve readability, especially as the logic grows.
GO TO FULL VERSION