Zum Inhalt springen

Projekt: Ratespiel in Rust

Das ist dein erstes echtes Projekt. Der Computer denkt sich eine geheime Zahl zwischen 1 und 100 aus. Du rätst. Er sagt dir „zu klein“ oder „zu groß“, bis du richtig liegst. Dann verrät er dir, wie viele Versuche du gebraucht hast.

Das Programm ist klein, aber es nutzt vieles, was du bisher gelernt hast: Variablen, Match, Schleifen und Result. Wir bauen es Schritt für Schritt, und jeder Schritt läuft für sich allein.

Wenn du fertig bist, sieht das Spiel auf deinem Computer so aus:

Errate die Zahl! (1 bis 100)
Bitte gib deinen Tipp ein:
50
Zu groß!
Bitte gib deinen Tipp ein:
hallo
Das ist keine ganze Zahl. Versuch es noch mal!
Bitte gib deinen Tipp ein:
25
Zu klein!
Bitte gib deinen Tipp ein:
37
Richtig! Du hast 3 Versuche gebraucht.

Achte auf zwei freundliche Details. Wenn jemand hallo eintippt, stürzt das Spiel nicht ab. Es fragt einfach noch mal. Und diese ungültige Eingabe zählt nicht als Versuch.

Öffne ein Terminal und lege ein neues Cargo-Projekt an:

Terminal window
cargo new guessing_game
cd guessing_game

Der ganze Code auf dieser Seite kommt in src/main.rs. Starten kannst du ihn jederzeit mit:

Terminal window
cargo run

Falls du Rust noch nicht installiert hast, schau zuerst bei Erste Schritte vorbei.

Um zu lesen, was der Spieler eintippt, nimmst du std::io::stdin() und die Methode read_line:

use std::io;
fn main() {
println!("Bitte gib deinen Tipp ein:");
let mut guess = String::new();
io::stdin()
.read_line(&mut guess)
.expect("Zeile konnte nicht gelesen werden");
println!("Dein Tipp: {guess}");
}

Dieses Programm wartet, bis du etwas eintippst und Enter drückst. Starte es also mit cargo run auf deinem eigenen Rechner. (Der Rust Playground hat keine Tastatureingabe. Deshalb gibt es hier keinen „Ausprobieren“-Button.)

Hier passiert einiges:

  • String::new() erzeugt einen leeren, wachsenden String, der den Text aufnimmt.
  • read_line(&mut guess) hängt die eingetippte Zeile ans Ende von guess an. Es braucht &mut, weil es den String verändert. Wenn &mut neu für dich ist, lies Borrowing (Ausleihen).
  • read_line gibt ein Result zurück, weil das Lesen der Eingabe fehlschlagen kann. .expect(...) bedeutet: „Wenn das schiefgeht, beende das Programm mit dieser Meldung.“ Für den Moment reicht das. Später gehen wir sanfter damit um.

Eine Überraschung: Der Text enthält auch die Enter-Taste. Wenn du 42 eintippst, steht in guess der Wert "42\n". Merk dir das für den nächsten Schritt.

Der Spieler hat Text eingegeben, wir wollen aber eine Zahl, die wir vergleichen können. .trim() entfernt Leerzeichen und den Zeilenumbruch an beiden Enden. Danach versucht .parse(), den Text in eine Zahl umzuwandeln.

Das kann schiefgehen. "sieben" ist keine Zahl. Darum gibt parse() ein Result zurück, und wir behandeln mit match beide Fälle:

fn main() {
// Stell dir vor, der Spieler hat diese Zeilen eingetippt (jede endet mit Enter).
let inputs = ["42\n", " 7 \n", "sieben\n", "-3\n"];
for input in inputs {
match input.trim().parse::<u32>() {
Ok(num) => println!("{:?} -> die Zahl {num}", input),
Err(e) => println!("{:?} -> kein gültiger Tipp ({e})", input),
}
}
}

Ausgabe:

"42\n" -> die Zahl 42
" 7 \n" -> die Zahl 7
"sieben\n" -> kein gültiger Tipp (invalid digit found in string)
"-3\n" -> kein gültiger Tipp (invalid digit found in string)
▶ Selbst ausprobieren

Der Text in Klammern, invalid digit found in string („ungültige Ziffer im String gefunden“), ist die Fehlermeldung, die Rust selbst liefert. Deshalb ist sie auf Englisch.

parse::<u32>() sagt Rust, in welchen Typ es umwandeln soll. u32 ist eine vorzeichenlose (also nie negative) ganze Zahl. Darum wird -3 abgelehnt. Genau das wollen wir: Die geheime Zahl liegt immer zwischen 1 und 100.

Im Spiel schreiben wir dieselbe Idee so:

let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Das ist keine ganze Zahl. Versuch es noch mal!");
continue;
}
};

Hier kommt der Typ aus let guess: u32, deshalb brauchen wir kein ::<u32>. Klappt das Umwandeln, liefert match die Zahl zurück. Klappt es nicht, geben wir eine freundliche Meldung aus und fragen mit continue noch einmal. (continue funktioniert nur in einer Schleife. Die bauen wir in Schritt 5 ein.)

Warum nicht einfach .expect()? Weil dann schon ein Tippfehler das ganze Spiel abstürzen ließe. Wenn du den Err-Fall behandelst, wird dein Programm angenehm zu benutzen.

Schritt 4: Den Tipp mit der geheimen Zahl vergleichen

Abschnitt betitelt „Schritt 4: Den Tipp mit der geheimen Zahl vergleichen“

Jede Zahl hat eine Methode .cmp(). Sie vergleicht zwei Werte und gibt ein std::cmp::Ordering zurück. Das ist ein Enum mit drei Varianten: Less (kleiner), Greater (größer) und Equal (gleich). Ein match darauf liest sich fast wie ein englischer Satz:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
for guess in [10, 42, 99] {
match guess.cmp(&secret) {
Ordering::Less => println!("{guess}: Zu klein!"),
Ordering::Greater => println!("{guess}: Zu groß!"),
Ordering::Equal => println!("{guess}: Gewonnen!"),
}
}
}

Ausgabe:

10: Zu klein!
42: Gewonnen!
99: Zu groß!
▶ Selbst ausprobieren

Du könntest dasselbe auch mit if guess < secret ... else if ... schreiben. Die match-Version hat einen Vorteil: Der Compiler prüft, ob du alle drei Fälle behandelt hast. Vergisst du einen, kompiliert das Programm nicht.

Jetzt packen wir alles in eine loop-Schleife. Sie läuft endlos, bis wir sie mit break verlassen. Außerdem führen wir einen Zähler, attempts, und erhöhen ihn nur, wenn der Tipp eine echte Zahl war.

Im echten Spiel kommt die Eingabe von der Tastatur. Damit eine Version im Playground läuft, nehmen wir eine feste geheime Zahl und eine feste Liste von „eingetippten“ Zeilen. inputs.next() liefert die nächste Zeile oder None, wenn die Liste zu Ende ist:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 37;
// Stell dir vor, der Spieler hat das eingetippt, eine Zeile pro Eintrag.
let mut inputs = ["50", "hallo", "25", "37"].into_iter();
let mut attempts = 0;
println!("Errate die Zahl! (1 bis 100)");
loop {
let input = match inputs.next() {
Some(line) => line,
None => {
println!("Keine Tipps mehr. Die Zahl war {secret}.");
break;
}
};
println!("> {input}");
let guess: u32 = match input.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Das ist keine ganze Zahl. Versuch es noch mal!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Zu klein!"),
Ordering::Greater => println!("Zu groß!"),
Ordering::Equal => {
println!("Richtig! Du hast {attempts} Versuche gebraucht.");
break;
}
}
}
}

Ausgabe:

Errate die Zahl! (1 bis 100)
> 50
Zu groß!
> hallo
Das ist keine ganze Zahl. Versuch es noch mal!
> 25
Zu klein!
> 37
Richtig! Du hast 3 Versuche gebraucht.
▶ Selbst ausprobieren

Ändere ruhig die geheime Zahl oder die Liste der Tipps und starte das Programm noch mal. Setz die richtige Antwort an den Anfang oder lass sie ganz weg und schau, was passiert.

Der Fall None ist wichtig. Er ist die Playground-Version von „der Spieler hat keine Eingabe mehr“. Das echte Spiel braucht dieselbe Idee (siehe Schritt 7).

Ein Spiel, bei dem die geheime Zahl immer gleich ist, macht nicht lange Spaß. In einem echten Projekt würdest du das rand-Crate nehmen (auf Englisch). Es ist die Standardwahl für Zufallszahlen in Rust. Wie du es benutzt, zeigt die Lektion Zufallszahlen. Du fügst es so hinzu:

Terminal window
cargo add rand

In diesem Projekt bleiben wir bei der Standardbibliothek, damit du nichts einrichten musst. Der Trick: Wir fragen die Uhr nach der aktuellen Zeit, nehmen den Nanosekunden-Anteil (eine Zahl von 0 bis 999.999.999, die sich ständig ändert) und quetschen ihn mit % (Rest einer Division) in den Bereich 1 bis 100:

use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("die Uhr steht vor dem Jahr 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
let secret = secret_number();
let in_range = (1..=100).contains(&secret);
println!("Geheimzahl gewählt. Liegt sie zwischen 1 und 100? {in_range}");
}

Ausgabe:

Geheimzahl gewählt. Liegt sie zwischen 1 und 100? true
▶ Selbst ausprobieren

nanos % 100 liegt immer zwischen 0 und 99. Mit + 1 landest du also bei 1 bis 100. Wir geben nur aus, ob die Zahl im Bereich liegt. So ist die Ausgabe jedes Mal gleich. Wenn du spicken willst, gib secret selbst aus.

Sei ehrlich zu dir, was das ist: „unvorhersehbar genug für ein Spiel“, aber kein echter Zufall. Nimm so einen Trick nie für Passwörter, Sicherheits-Tokens oder irgendetwas, das jemand erraten wollen könnte. Dafür gibt es richtige Crates.

Hier ist alles zusammen. Diese Version kommt in src/main.rs, und mit cargo run kannst du spielen.

Eine Neuerung: Wir machen ein match auf das Ergebnis von read_line, statt .expect() aufzurufen. read_line gibt Ok(0) zurück, wenn keine Eingabe mehr kommt. Das passiert zum Beispiel, wenn der Spieler Strg+D drückt (unter Windows Strg+Z und dann Enter) oder wenn die Eingabe per Pipe aus einer Datei kommt. Ohne diese Prüfung würde die Schleife bei leerer Eingabe endlos weiterlaufen.

use std::cmp::Ordering;
use std::io;
use std::time::{SystemTime, UNIX_EPOCH};
fn secret_number() -> u32 {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("die Uhr steht vor dem Jahr 1970")
.subsec_nanos();
nanos % 100 + 1
}
fn main() {
println!("Errate die Zahl! (1 bis 100)");
let secret = secret_number();
let mut attempts = 0;
loop {
println!("Bitte gib deinen Tipp ein:");
let mut guess = String::new();
match io::stdin().read_line(&mut guess) {
Ok(0) => {
// Ende der Eingabe: Es gibt nichts mehr zu lesen.
println!("Keine Eingabe mehr. Die Zahl war {secret}. Tschüss!");
break;
}
Ok(_) => {}
Err(e) => {
println!("Deine Eingabe konnte nicht gelesen werden: {e}");
break;
}
}
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => {
println!("Das ist keine ganze Zahl. Versuch es noch mal!");
continue;
}
};
attempts += 1;
match guess.cmp(&secret) {
Ordering::Less => println!("Zu klein!"),
Ordering::Greater => println!("Zu groß!"),
Ordering::Equal => {
println!("Richtig! Du hast {attempts} Versuche gebraucht.");
break;
}
}
}
}

Wenn du es lieber im Browser ausprobieren willst, nimm die Version aus Schritt 5. Sie spielt eine feste Liste von Tipps durch. 🦀

mut beim String vergessen. read_line muss den String verändern. Deshalb muss er veränderbar sein:

use std::io;
fn main() {
let guess = String::new();
io::stdin().read_line(&mut guess).expect("Zeile konnte nicht gelesen werden");
}
error[E0596]: cannot borrow `guess` as mutable, as it is not declared as mutable
--> src/main.rs:5:27
|
5 | io::stdin().read_line(&mut guess).expect("Zeile konnte nicht gelesen werden");
| ^^^^^^^^^^ cannot borrow as mutable
|
help: consider changing this to be mutable
|
4 | let mut guess = String::new();
| +++

Der Compiler sagt: „guess kann nicht veränderbar ausgeliehen werden, weil es nicht als veränderbar deklariert ist.“ Die Lösung ist genau sein Vorschlag: let mut guess = String::new();.

Text mit einer Zahl vergleichen. Wenn du das Umwandeln vergisst, ist guess immer noch ein String. Und einen String kannst du nicht mit einem u32 vergleichen:

use std::cmp::Ordering;
fn main() {
let secret: u32 = 42;
let guess = String::from("50");
match guess.cmp(&secret) {
Ordering::Less => println!("Zu klein!"),
Ordering::Greater => println!("Zu groß!"),
Ordering::Equal => println!("Gewonnen!"),
}
}
error[E0308]: mismatched types
--> src/main.rs:7:21
|
7 | match guess.cmp(&secret) {
| --- ^^^^^^^ expected `&String`, found `&u32`
| |
| arguments to this method are incorrect

„Mismatched types“ heißt „Typen passen nicht zusammen“: Erwartet wurde &String, gefunden hat der Compiler &u32. Rust wandelt nie von selbst zwischen Text und Zahlen um. Erst umwandeln, dann vergleichen.

.trim() vergessen. Das kompiliert zwar, aber jeder Tipp wird abgelehnt, weil der Zeilenumbruch keine Ziffer ist:

fn main() {
let typed = "42\n";
println!("{:?}", typed.parse::<u32>());
println!("{:?}", typed.trim().parse::<u32>());
}

Ausgabe:

Err(ParseIntError { kind: InvalidDigit })
Ok(42)
▶ Selbst ausprobieren

Wenn dein Spiel läuft, probier dich an diesen Ideen. Jede ist klein:

  • Den Bereich prüfen. Ist der Tipp 0 oder größer als 100, gib „Bitte wähle eine Zahl von 1 bis 100“ aus und zähle ihn nicht mit.
  • Die Versuche begrenzen. Gib dem Spieler 7 Versuche. Sind sie aufgebraucht, zeig die geheime Zahl und beende das Spiel.
  • Noch mal spielen. Wenn der Spieler gewinnt, frag „Noch eine Runde? (j/n)“ und starte eine neue Runde mit einer neuen geheimen Zahl.
  • Einen Rekord speichern. Merk dir die wenigsten Versuche über alle Runden und gib sie am Ende aus.
  • Wärmere Hinweise geben. Sag „Ganz knapp!“, wenn der Tipp höchstens 3 von der geheimen Zahl entfernt ist. guess.abs_diff(secret) liefert den Abstand zwischen zwei Zahlen.
  • Das echte rand-Crate nehmen. Führe cargo add rand aus und ersetze secret_number() mithilfe der Dokumentation auf docs.rs (auf Englisch).
  • Logik in Funktionen auslagern. Schreib fn check_guess(guess: u32, secret: u32) -> Ordering und füge Tests dafür hinzu.
  • io::stdin().read_line(&mut s) liest eine Zeile mitsamt Zeilenumbruch und gibt ein Result zurück. Ok(0) bedeutet: Es kommt keine Eingabe mehr.
  • .trim().parse() wandelt Text in eine Zahl um. Es gibt ein Result zurück. Behandle es mit match, damit ungültige Eingaben freundlich abgefangen werden.
  • a.cmp(&b) gibt Ordering::Less, Greater oder Equal zurück, und match sorgt dafür, dass du alle drei Fälle behandelst.
  • loop mit continue und break hält das Spiel am Laufen, bis der Spieler gewinnt.
  • Der Uhr-Trick reicht für ein Spiel. Echte Projekte nehmen das rand-Crate.

Kurzer Check

1. Der Spieler tippt 42 und drückt Enter. Was steht in dem String von read_line?

2. Warum nutzt das Spiel match auf parse() statt .expect()?

3. Was bedeutet es, wenn read_line Ok(0) zurückgibt?

4. Welche Werte kann guess.cmp(&secret) zurückgeben?

Punkte: 0 / 4