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.

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 tries starts unset rather than at 0, then tries += 1 never produces a number, over() can never be true, and the game would loop forever. The test failed at the first over() check and pointed straight at the fix: declare the field as tries 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

  1. Add a -t tries option so the player can choose how many guesses they get, as -p chooses the name. Which function changes, and which do not?
  2. Give Score a different $, perhaps Sam - 6 tries. Run the tests. Which test do you have to update, and why is it good that one failed?
  3. 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.

  1. 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. ↩

  2. 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 (error E07175), 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. ↩