JavaRush/Courses/Kotlin SELF/Game rules and state

Game rules and state

Open

1. Why rules matter in a game

When we write 5-line tutorial examples, the brain can calmly hold 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, it’s sort of like this here”, “and here, just in case, it’s different”. So today we’re not making a “game”, we’re making 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 until the moment you try to explain to the computer what an “attempt” and “invalid input” mean. A computer is a straightforward creature. If you don’t say that invalid input does not consume an attempt, it will “count” however it ends up counting (usually wrong, but confidently).

To avoid treating symptoms later, we’ll start with the dullest (and most useful) part of development: translating requirements into variables and checks.

2. Game rules: phrasing the requirements

Any program starts with text in human language: “the user does this”, “if this happens — then that”. We must turn that text into rules that cannot be interpreted in two ways. Today our task is to assemble the minimal set of rules that will make the game fair and predictable: the secret’s range, the attempt limit, hints, and end conditions. We are not writing the entire game loop yet; we are building a foundation it won’t collapse on.

Basic set of rules

Let’s imagine the game requirements 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, print “too big”.
  • If it’s equal, print “you guessed it!” 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), you must decide in advance: does it consume an attempt or not.

Notice: the last item is not an “implementation detail”, it’s a real game rule. Different versions of the game do it differently, and both options are fine. Only one thing is bad: not deciding it ahead of time.

Invalid input: consume an attempt or not

Because it determines where attempt++ will appear in your loop. And this is a classic place for off-by-one bugs: you did everything “right”, but the game suddenly gives one extra attempt or one fewer, or gets stuck in a “enter a number” loop.

Today we’ll choose one option and follow it. For the first version of the game we’ll use a friendly policy:

Invalid input does NOT consume 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” with no warning. So we introduce an important separation: settings and state.

Settings are what define the game scenario and do not change as the game runs: the secret range, the attempt limit, maybe text messages. State is what changes: the current attempt number, whether we’ve won, the current guess, etc.

In Kotlin this is very conveniently expressed 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 a week later (or two hours later). And var is an honest admission: “this will change”.

Settings vs state: cheat sheet

Entity Name example val or var Why
Secret range
min, max
val
The game rules should not “drift”
Attempt limit
maxAttempts
val
Otherwise you can “accidentally enable cheats”
Current attempt
attempt
var
Grows with each consumed attempt
Win/loss
isWon
var
At first you haven’t won, then (maybe) you have

4. Game state: what to store

The word “state” sounds philosophical: “the state of the program’s spirit”. 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 stop understanding yourself why the game ended.

We’ll start with the minimal state that is sufficient for a normal scenario:

  • the current attempt number;
  • the win flag (isWon);
  • (a bit later) the secret number;
  • (inside a loop iteration) the current guess.

Why you need the isWon flag

You can 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 command exit), “early exit” stops meaning a win.

The isWon flag makes the final output unambiguous and readable: if (isWon) ... else ....

Mini example: temporarily fixing the secret

Important: in the next lecture we’ll generate the secret via Random correctly and without off-by-one errors. For now we’re not about generation, we’re about structure. So we’ll temporarily fix the secret as a constant — it’s also convenient for checking 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” the 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 a “how is this possible?” face.

We’ll introduce a few invariants and decide where exactly to check them in order to catch errors early.

Examples of invariants

Invariant #1: min <= max. If the range is “flipped”, all checks like x in min..max will behave unexpectedly.

Invariant #2: maxAttempts > 0. Otherwise the game is either immediately lost, or the loop doesn’t start at all.

Invariant #3: the secret must be within the range. Today the secret is fixed, but the rule still exists.

Invariant #4: attempt must not “fly away” to 0 or negative values. For a human this is “obvious”; for a program it’s only true if you write it.

Validating settings via require(...)

You’ve already seen require in the topic about preconditions and fail-fast: if input parameters are invalid, it’s better to crash immediately with a clear message than to keep running in an invalid state.

Let’s make a small settings-validation function:

fun validateGameSettings(min: Int, max: Int, maxAttempts: Int) {
    require(min <= max) { "Invalid range: min=$min, max=$max" }
    require(maxAttempts > 0) { "maxAttempts must be > 0, now $maxAttempts" }
}

This function returns nothing (i.e., returns Unit); its job is to either confirm that the settings are correct, or stop the program with a clear reason.

Using 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
}

So far this looks like “too much code for a single line”, but in a project it pays off very quickly. Settings errors 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 into five places, one of them will inevitably contain “almost the same thing”, but not quite. So even a simple rule like “a number must be within the range” is useful to wrap into a separate function so you can read code like a story: “checked the range → moved on”.

In this section we’ll lock in a few convenient “contracts”: about the range, about the comparison result, and about where exactly the 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 happening, without decoding x in min..max among other conditions.

Three comparison outcomes: less / greater / equal

When a person plays, they think in text: “less”, “greater”, “guessed it”. 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 people use a result code for this: -1, 0, 1. This is not “number magic” 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”.

Without the full comparison yet, we’ll just show the idea: by the result code you can choose the text.

fun hintText(result: Int): String {
    return when (result) {
        TOO_SMALL -> "Too small"
        TOO_BIG -> "Too big"
        else -> "You guessed it!"
    }
}

In this lecture, what matters is not that else catches “guessed it”, but that we begin to think in contracts: “the function receives one of three codes and returns clear text”. Contracts are like traffic rules for your functions. Without them, code starts “crossing the road anywhere”.

An agreement about attempts and attempt++

If there were a world championship for bugs, the error “attempt++ is in the wrong place” would confidently make the top 3. The problem is that in a human’s head an “attempt” is an abstract step, but in code an “attempt” must be a specific point where you incremented the counter. If you increment the counter too early, you get a win “in 8 attempts out of 7”. If you don’t increment it, you get an infinite loop.

We already chose the rule: invalid input does not consume an attempt. Therefore:

  • when the input is invalid, we show a message and repeat the same attempt;
  • when the input is valid but not guessed, the attempt is consumed and we increment attempt.

We are not writing the full loop yet, but we can already mark the place for attempt++ logically: it comes after a valid attempt ends in failure.

Attempt status text

When you build the main loop, there will already be plenty of logic: input, checking null, comparison, break/continue. So any small “cosmetic” things are better to format ahead of time so the loop doesn’t have extra noise. One such noise is forming 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 what it looks like when printed:

val attempt = 1
val maxAttempts = 7
println(attemptStatusText(attempt, maxAttempts)) // Attempt 1 of 7

This is not “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 which variables must exist for each stage to be possible.

This structure is like a hiking plan. Even if you don’t yet know how to build a campfire (the attempt loop), you at least know where you’re going and what you’re bringing (settings and state).

Scenario flowchart

flowchart TD
    A[Initialization of settings] --> B[Validate settings require]
    B --> C[Create initial state]
    C --> D[Attempt loop: input → validation → hint]
    D --> E{Guessed?}
    E -->|yes| F[Win: print result]
    E -->|no| G{Attempts exhausted?}
    G -->|yes| H[Loss: print secret]
    G -->|no| D

Today we complete the first three blocks: settings, validation, state. We’ll cover the loop and secret generation in the next lectures.

main skeleton 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.
}

So far the game doesn’t play, 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 #1: making settings var because “that’s what I’m used to”.
In small examples you won’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 a match.

Mistake #2: not agreeing whether invalid input consumes an attempt.
If you don’t decide this in advance, attempt++ starts appearing “somewhere nearby”: you incremented once, forgot the second time, incremented twice the third time. As a result, attempt logic spreads out and you catch off-by-one bugs that are unpleasant to debug.

Mistake #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: settings first (val), then state (var). This is not a mandatory Kotlin language rule, but it’s a very useful rule for your head.

Mistake #4: not checking invariants via require and hoping for luck.
If min > max, the game will work “somehow”, and you may spend a long time not understanding why input “within the range” fails validation. require lets you crash immediately with a clear message, instead of turning debugging into archaeology.

Mistake #5: using “magic numbers” instead of meaningful names.
If your code contains 7, 100, -1, 0, 1 without explanation, you’ll end up reading the program like a riddle. Even simple constants like maxAttempts and TOO_SMALL sharply improve readability, especially as the logic grows.

1
Task
Kotlin SELF,  15 level,  0 lecture
Locked
Game start
Game start
1
Task
Kotlin SELF,  15 level,  0 lecture
Locked
Settings validation
Settings validation
1
Task
Kotlin SELF,  15 level,  0 lecture
Locked
Guess filter
Guess filter
1
Task
Kotlin SELF,  15 level,  0 lecture
Locked
Judge hint
Judge hint
Комментарии
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны sign in
This page has no comments yet