Programming a Guessing Game
Let's write a whole program together: the classic guessing game. The program picks a random number between 1 and 100 and asks the player to guess it, saying after each guess whether it was too small or too big. Along the way you'll meet variables, match, loops, Option, and, most of all, how capabilities flow through a program.
Don't worry about understanding every detail yet. The next chapters go over each idea slowly; this one is about seeing them work together.
Reading a guess
First, ask for a guess and print it back.
fn main(root: Root) {
root.stdout.print("Guess the number!")
root.stdout.write("Your guess: ")
let guess = root.stdin.read_line() ?? ""
root.stdout.print("You guessed {guess}.")
}root.stdout.write is like print without the newline, so the player types on the same line as the prompt.
root.stdin is the capability for standard input, and read_line() reads one line from it, without the newline at the end. Input can run out (the player might press Ctrl-D, or the input might come from a file that has ended), so read_line doesn't return a Str. It returns an Option[Str]: Some(line) when there was a line, None at the end of input. The ?? operator gives us the line, or the empty string when there was none.
let guess = ... introduces a variable. Its type, Str, comes from the value; you can write it out as let guess: Str = ... when you want to, but you rarely need to.
When you run this yourself, you type the answer. For this book, the answers come from a file, so that the output can be checked every time the book is built:
$ tessera run main.tsr < input.txt
Guess the number!
Your guess: You guessed 42.
The 42 you'd type doesn't appear in the output, because it came from the file rather than the keyboard; that will be true of every transcript in this chapter.
Picking a secret number
Next, the program needs a random number. Randomness is a capability too: root.rand, of type Rand. Like standard input, output and error and the clock, it is always granted, but it still has to be reached through root, which matters as soon as we split the program into functions.
fn main(root: Root) {
root.stdout.print("Guess the number!")
let secret = root.rand.int(1, 101)
root.stdout.print("(The secret number is {secret}.)")
root.stdout.write("Your guess: ")
let line = root.stdin.read_line() ?? ""
let guess = line.parse_int() ?? 0
if guess < secret {
root.stdout.print("Too small!")
} else if guess > secret {
root.stdout.print("Too big!")
} else {
root.stdout.print("You win!")
}
}root.rand.int(1, 101) gives a number from 1 up to, but not including, 101. Tessera's ranges always work this way, including the start and stopping before the end.
line.parse_int() turns text into a number. Text might not be a number, so it returns an Option[Int]; for now, anything that isn't a number counts as 0.
if, else if and else work as you would expect. Conditions need no parentheses, and the braces are required.
To test a program that uses random numbers, you want the numbers to be the same every run. --seed does that: with the same seed, Rand gives the same numbers, in the interpreter and in native code alike.
$ tessera run main.tsr --seed 1 < input.txt
Guess the number!
(The secret number is 66.)
Your guess: Too small!
$ tessera run main.tsr --seed 2 < input.txt
Guess the number!
(The secret number is 11.)
Your guess: Too big!
Looping until the guess is right
Now the real game. It keeps asking until the guess is right, ignores input that isn't a number, and says how many tries it took. It's also time to split the program into functions.
fn main(root: Root) {
root.stdout.print("Guess the number between 1 and 100!")
let secret = root.rand.int(1, 101)
let tries = play(root.stdin, root.stdout, secret)
if tries > 0 {
root.stdout.print("You got it in {tries} tries.")
}
}
// Asks for guesses until one is right. Returns how many it took, or 0 if
// the input ran out first.
fn play(input: Stdin, out: Stdout, secret: Int) -> Int {
let mut tries = 0
while true {
out.write("Your guess: ")
let line = match input.read_line() {
Some(text) => text,
None => {
return 0
},
}
let guess = match line.parse_int() {
Some(n) => n,
None => {
out.print("\"{line}\" is not a number.")
continue
},
}
tries = tries + 1
out.print(judge(guess, secret))
if guess == secret {
break
}
}
tries
}
// No capabilities: this function can only compute.
fn judge(guess: Int, secret: Int) -> Str {
if guess < secret {
"{guess} is too small."
} else if guess > secret {
"{guess} is too big."
} else {
"{guess} is right!"
}
}Look at the signatures before the bodies, because they tell the most interesting part of the story.
main takes root: Root, which can do everything the program was granted.
play takes input: Stdin and out: Stdout. It can read lines and print, and nothing else: it cannot open a file, reach the network or even look at the clock, because it was never given those capabilities and there is no other way to get them. main calls it with root.stdin and root.stdout, handing over just those two pieces of its authority.
judge takes two numbers and returns a string. It has no capability parameters at all, so it can only compute. Functions like this are called pure, and they're the easiest kind to test and to trust.
You don't have to take this on faith. tessera caps lists what each function can reach:
$ tessera caps main.tsr
main Root
play Stdout, Stdin
judge pure
Now the bodies.
let mut tries = 0 declares a variable that can change. Variables are immutable unless you say mut; chapter 3 explains why that's the default.
while true { ... } loops until something leaves it: break leaves the loop, continue starts the next round, and return leaves the whole function.
match looks at a value and picks the first arm whose pattern fits. In
let line = match input.read_line() {
Some(text) => text,
None => {
return 0
},
}
the Some(text) arm matches when there is a line, and binds it to the name text; the None arm runs when the input has ended. A match is an expression, so its value (here, the line) can go straight into a let. The compiler checks that the arms cover every case. Forget the None arm and the program does not compile.
The second match, on line.parse_int(), does the same for numbers: a number continues the game, anything else prints a message and goes on to the next round with continue.
judge shows that if is an expression too: its value is the value of the branch that runs, so the function's body is just one if whose branches are strings. The last expression in a function body is what it returns.
Here's a game, with the player's answers in input.txt:
$ cat input.txt
50
seventy
75
62
68
65
66
$ tessera run main.tsr --seed 1 < input.txt
Guess the number between 1 and 100!
Your guess: 50 is too small.
Your guess: "seventy" is not a number.
Your guess: 75 is too big.
Your guess: 62 is too small.
Your guess: 68 is too big.
Your guess: 65 is too small.
Your guess: 66 is right!
You got it in 6 tries.
Summary
In a few dozen lines you've used variables and mut, if, while, match, Option and ??, functions with typed parameters, and three capabilities. More importantly, you've seen the pattern every Tessera program follows: main holds the authority, and it passes each function only the part that function needs. The type signatures then tell you, and the compiler, exactly what each part of the program can do.
Next, we'll slow down and look at the common building blocks one at a time.