Getting Started with EK9 · Chapter 23
Build a Game, End to End
This chapter puts the whole of this book into one program you would want to run: a number-guessing game. The computer picks a number, you try to find it, and after each guess it tells you whether to aim higher or lower. When the game ends it prints a high-score table — fewest guesses wins.
The game is not new. Guess-the-number is one of the oldest teaching projects there is, written by
beginners in many books and many languages.1 The code is built from what you have already met:
records and classes, collections, functions, operators, GetOpt, and a set of tests that ties it
together. The few small additions from the standard library are explained where they appear. The skill
on show is how the pieces fit. Here is a real game, played to the end:
I am thinking of a number from 1 to 100. You have 7 tries.
50: lower
25: higher
37: higher
43: higher
46: lower
44: correct
Well done Sam - 6 tries.
Fewest tries wins:
Ada (3)
Alan (5)
Sam (6)
By the end of the chapter you will have built that, tested the parts that can be tested, and seen why one part cannot be.
A plan that fits in your head
You could write the whole game as one long program: read a guess, compare it, print a hint, loop, keep score, show the table. It would work. But one long program is very hard to test. You cannot ask "is the scoring right?" without playing a whole game by hand, and you cannot play it by hand a hundred times to be sure.
So we do the opposite, and it is one of the most useful habits in this book: move the decisions out of the program and into small, named pieces, each doing one thing you can check on its own. The program itself becomes a thin shell — read a line, print a line — and the real decisions live in a record, a class and a few functions that can be tested thoroughly.
There is a concrete reason, from Testing as You Go: EK9 measures how much of your code the tests exercise, and requires a minimum. The more of your logic sits in small testable pieces, the easier that minimum is to reach. Good structure and good testing are the same thing seen from two sides.
The plan: a Score record for one line of the table; a Game class for one game in progress; a few
small functions — hint, rank, and helpers for the board; and a short program that connects them to
the keyboard.
A line on the board: the Score record
A score is a name and a number of tries — plain data — so it is a record:
Score
who as String: String()
attempts as Integer: Integer()
default Score() as pure
Score() as pure
->
name as String
tries as Integer
who :=? String(name)
attempts :=? Integer(tries)
operator $ as pure
<- rtn as String: `${who} (${attempts})`
operator <=> as pure
-> other as Score
<- rtn as Integer: attempts <=> other.attempts
operator #? as pure
<- rtn as Integer: #?attempts
default operator ?
Three operators are written by hand.
$gives aScoreits text form,Sam (6), so one can go straight into a`...`string.<=>compares onattempts, so sorting a list of scores puts the fewest tries first. That one operator is what sorts the high-score table later, with no sorting code of our own. Because<=>is how aScoresays what "the same" means, two scores with equal tries are equal:Ada (3) == Bob (3)istrue. For a table ordered by tries, that is what "the same" should mean.#?, the hash code (the number collections use to find a value quickly), goes with<=>. A hand-written<=>needs a hand-written#?that hashes what it compares — here,attempts— so the two always agree. The compiler requires the pair (E07184if the#?is missing).
default operator ? supplies the set check every record with fields needs, and default Score()
gives a blank score to fall back on. The constructor copies what it is given — String(name),
Integer(tries) — so a Score owns its values.
One game in progress: the Game class
A record holds data; a class holds data and protects it. A game has state that must stay consistent — the secret number, how many tries are allowed, how many have been used, whether it has been won — so it is a class, with those fields hidden inside:
Game
secret as Integer: Integer()
maxTries as Integer: Integer()
tries as Integer: 0
solved as Boolean: false
default private Game() as pure
Game() as pure
->
chosen as Integer
allowed as Integer
secret :=? Integer(chosen)
maxTries :=? Integer(allowed)
//Take one guess. Count it, say which way to go, and note a win.
guess()
-> number as Integer
<- rtn as String: String()
tries += 1
rtn: hint(secret, number)
if number == secret
solved := true
over() as pure
<- rtn as Boolean: solved or tries >= maxTries
triesUsed() as pure
<- rtn <- Integer(tries)
report() as pure
-> player as String
<- rtn as String: String()
rtn: solved <- `Well done ${player} - ${tries} tries.` : `Out of tries - the number was ${secret}.`
default operator ?
Everything a game does is a method. guess takes a number, counts the attempt, works out the hint
and records a win. over says whether the game has finished — solved, or out of tries. triesUsed
reports the count, and report produces the closing line, using the ternary from Guards and
Safe Code (solved <- winMessage : loseMessage) to choose one of two sentences.
default private Game() is the blank constructor — a Game with no secret and no limit — and
making it private means no code outside the class can build a half-made game.2 A game with
nothing to guess is not a game, so the only way to make one is Game(secret, tries). Score made the
opposite choice and left its blank constructor public, because a blank score is a useful "nothing
yet": the tests use Score() as the fallback when they read the top of a ranked board. Same tool,
opposite choice, each following what the type means.
The class also copies at both doors. On the way in, the constructor stores Integer(chosen), not
chosen. On the way out, triesUsed returns Integer(tries), not the field. As Classes
explained, an Integer is held by reference, so holding — or handing out — another piece of code's
object would let that code change the game's secret or its count from outside. With the copies and the
private blank constructor, the class has full control of its state: you cannot make a game without a
secret, and you cannot change one it already has.
All this is in a class because a class can be tested. A real game needs a random secret; to test
one, we build a Game with a secret we already know, and play it:
@Test WinningGame() game <- Game(42, 7) assert game.guess(50) == "lower" assert not game.over() assert game.guess(42) == "correct" assert game.over() assert game.triesUsed() == 2 assert game.report("Sam") == "Well done Sam - 2 tries."
That is a whole game, played in a test, with a secret of 42. Guess 50 and it says lower; the
game is not over. Guess 42 and it says correct; now it is over, in two tries, and the report reads
as it should. A losing game is as easy to check — run a Game out of tries and confirm it admits
defeat and names the number:
@Test LosingGame() game <- Game(30, 3) assert game.guess(10)? assert game.guess(90)? assert game.guess(25)? assert game.over() assert game.report("Sam") == "Out of tries - the number was 30."
Between them, these two tests exercise every branch in Game: the win, the loss, the counting, and
both halves of report. That is the benefit of keeping the game's logic in a class rather than in the
program.
Note. Writing the first test exposed a real bug. If
triesstarts unset rather than at0, thentries += 1never produces a number,over()can never betrue, and the game would loop forever. The test failed at the firstover()check and pointed straight at the fix: declare the field astries as Integer: 0.
The small helpers
The rest of the logic is a few plain functions. hint is the heart of the game, and it is pure —
the same guess always gives the same word:
hint() as pure -> secret as Integer guess as Integer <- rtn as String: String() order <- guess <=> secret rtn: order < 0 <- "higher" : order > 0 <- "lower" : "correct"
It compares the guess with the secret using <=>, which gives a negative number, zero or a positive
number. The last line is two ternaries in a row: the value after the first : is itself a ternary.
Read it left to right: if order < 0 the answer is higher; otherwise, if order > 0 it is lower;
otherwise it is correct. Because it is a small pure function, one line tests each
case:
@Test HigherTest() assert hint(50, 20) == "higher"
rank puts a board in order. It is one stream pipeline. Its sort stage puts the items in order using
their <=> — here the one we gave Score — just as Using Operators sorted a List of Release; then
collect gathers the result back into a list:
rank() as pure -> board as List of Score <- rtn as List of Score: List() of Score rtn: cat board | sort | collect as List of Score
Three more helpers build and display the table: startingBoard (the scores already there),
finalBoard (add this game's result) and boardLines (turn the ranked board into text, best first):
boardLines()
-> board as List of Score
<- rtn as String: ""
for entry in rank(board)
rtn += ` ${entry}\n`
Each of these can be called from a test with a known input and its answer checked. None of them needs a keyboard or a running game.
The program: a thin shell
Now the part that talks to a person, and very little is left for it to do. It reads the player's name from the command line, asks the operating system for a random secret, then loops: read a line, pass it to the game, print the hint. When the game is over it prints the report and the table.
Guess()
-> argv as List of String
stdin <- Stdin()
stdout <- Stdout()
player <- playerName(argv)
high <- 100
maxTries <- 7
game <- Game(OS().random(1, high), maxTries)
stdout.println(`I am thinking of a number from 1 to ${high}. You have ${maxTries} tries.`)
while not game.over()
number <- Integer(stdin.next())
stdout.println(` ${number}: ${game.guess(number)}`)
stdout.println(game.report(player))
stdout.println("Fewest tries wins:")
stdout.print(boardLines(finalBoard(player, game.triesUsed())))
A few new names, all small. OS() reaches the machine the program is running on; OS().random(1, high) picks the secret. Stdin() reads what the player types, a line at a time, with stdin.next().
And stdout.print is println without the line break at the end; the board text already ends with
one. playerName uses GetOpt to read an optional -p name from the command line, falling back to
"Player", as in Programs, Input and Output.
The loop has no break and no return to jump out of the middle; EK9 has neither. It runs
while not game.over(), and the game decides when that becomes true. The condition says when the loop
ends; there is no hidden exit.
Every real decision — what the hint is, whether the game is over, how the report reads, how the board sorts — has already been made, and tested, elsewhere. The program only carries text between the player and the pieces that decide.
Running the tests
Fifteen tests cover the record, the class and the functions. Run them with ek9 -t (add -tC to
print the coverage summary):
ℹ️ Found 15 tests:
15 assert (unit tests with assertions)
Executing 15 tests...
✅ PASS book.guess.tests::BoardLinesListsEveryone [Assert] (6ms)
✅ PASS book.guess.tests::BoardOpensWithAda [Assert] (6ms)
✅ PASS book.guess.tests::CorrectTest [Assert] (4ms)
✅ PASS book.guess.tests::EqualScoresHashAlike [Assert] (4ms)
✅ PASS book.guess.tests::FewerTriesComeFirst [Assert] (4ms)
✅ PASS book.guess.tests::GameIsSet [Assert] (4ms)
✅ PASS book.guess.tests::HigherTest [Assert] (4ms)
✅ PASS book.guess.tests::LosingGame [Assert] (5ms)
✅ PASS book.guess.tests::LowerTest [Assert] (4ms)
✅ PASS book.guess.tests::PlayerNameDefaults [Assert] (1ms)
✅ PASS book.guess.tests::PlayerNameFromFlag [Assert] (0ms)
✅ PASS book.guess.tests::RankPutsFewestFirst [Assert] (6ms)
✅ PASS book.guess.tests::ScoreShowsNameAndTries [Assert] (4ms)
✅ PASS book.guess.tests::TopScoreJoinsBoard [Assert] (6ms)
✅ PASS book.guess.tests::WinningGame [Assert] (5ms)
Summary: 15 passed, 0 failed (15 total)
Types: 15 assert
Duration: 69ms
With -tC, the same run ends with the coverage summary:
📊 Coverage Summary
✅ Methods: 94.4% (17/18)
✅ Lines: 82.6% (71/86)
✅ Branches: 88.9% (8/9)
✅ Overall: 83.70% (77/92 probes hit)
📦 By Module:
✅ book.guess: 83.70% (77/92)
All pass, and coverage is above the minimum. But seventeen of eighteen methods are covered, not all
eighteen. The one never run is Guess itself: the program, with its interactive loop.
That gap is deliberate. You cannot unit-test a conversation. The loop's job is to talk to a person at a keyboard and to use a random secret from the operating system, and a test has neither. Everything that decides is tested; the only part left uncovered is the thin part that talks. That is why we made it thin.
Testing even that shell — giving the program a pretend player who types a fixed script, and a pretend "random" that always returns the same number — is possible, and the second volume shows how. There you learn to inject a program's surroundings, so the loop comes under test too. For now: the more you break a program into small pieces, the more of it you can show to be correct.
Playing it
Compiled and run, the game plays as it should. A win, guided by the hints:
I am thinking of a number from 1 to 100. You have 7 tries.
50: lower
25: higher
37: higher
43: higher
46: lower
44: correct
Well done Sam - 6 tries.
Fewest tries wins:
Ada (3)
Alan (5)
Sam (6)
And a game that runs out of tries: after seven guesses, the program reveals the secret and still records the score at the bottom of the table:
I am thinking of a number from 1 to 100. You have 7 tries.
10: higher
20: higher
30: higher
40: lower
60: lower
70: lower
80: lower
Out of tries - the number was 36.
Fewest tries wins:
Ada (3)
Alan (5)
Lee (7)
Both are real runs. The secret comes from OS().random, so it is different every time.
Where to take it next
The high-score table lasts only as long as the program runs. The next step is to save it: write
the board to a file when the game ends, and read it back when the next game starts, so scores carry
over between games. That is the file work of Programs, Input and Output, and rank and boardLines
already exist, so only the reading and writing are new.
Beyond that is the second volume, EK9 in Depth, where the things a program talks to — files, the clock, randomness, other services — become things you can replace with stand-ins, so that even the parts left untested here come under test.
Try it yourself
- Add a
-t triesoption so the player can choose how many guesses they get, as-pchooses the name. Which function changes, and which do not? - Give
Scorea different$, perhapsSam - 6 tries. Run the tests. Which test do you have to update, and why is it good that one failed? - Save the high-score table to a file at the end of the game and load it at the start, using the file work from Programs, Input and Output. Play twice and see a score survive.
Recap
You built a complete, playable program from the whole of this book: a Score record with a
hand-written <=> to sort the table; a Game class that holds one game's state and hides it behind
methods; small functions — hint, rank and the board helpers — that each do one thing; and a
short program that connects them to Stdin, OS().random and GetOpt. The organising idea, and
one of the most useful habits in the book, was to keep the program thin and move every decision into a
piece you can test. That let fifteen tests cover the record, the class and the functions, and clear
the coverage minimum from Testing as You Go. The one part left uncovered was the interactive loop,
because you cannot unit-test a conversation — and that gap leads into the second volume, where a
program's surroundings can be tested too.
That closes this book. You began with a program that printed one line; you can now design types, choose between a record and a class, write and test functions, work with collections and streams, read the compiler when it stops you, and combine all of it into a program a person can sit down and play. The second volume turns from using the language to why it works the way it does, and to building much larger programs.
-
The number-guessing game is one of the best-known examples in programming. It is the worked project in Chapter 2 of The Rust Programming Language ("Programming a Guessing Game"), and versions of it go back decades — a "guess the number" game appears in David Ahl's BASIC Computer Games (1973), older than most languages in use today. It lasts because it is a small, complete problem — a little input, a loop, a decision, a little state — which makes it a good first project, and a good last chapter. ↩
-
EK9 has a firm rule behind this. When a class or record has an uninitialised field — one declared
x as Type?with no starting value — the compiler requires the blank, no-argument constructor to be private (errorE07175), so a half-built object can never get out.Game's fields all have starting values, so the compiler does not require it here; we make the constructor private ourselves, for the same reason. The rule states an intent: a type should never be constructible in a state it cannot honour. ↩