Skip to content
gohacks.dev
Basics

Build a Go Guessing Game Step by Step With Unit Tests

Build a number guessing game in Go from an empty folder: input handling, a testable game loop, table-driven tests and a bisection check.

17 min read
Summarize in AI
Flat illustration of a test report with one failing test.

A guessing game is the small program that touches the most of Go's basics at once: reading input, converting a string to a number, a loop with a branch, a random number, a function that returns a value, and a clean exit code. It is also a good first test target, because the interesting part, the rules, can be separated from the boring part, the terminal. This project builds it from an empty folder, runs it, and then tests it properly, with scripted input and a check over every possible secret. By the end you have a small program, a test file that covers it, and you will have used most of what the Go basics path introduces.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

You need Go installed (how to install Go) and a terminal. Everything below was run on Go 1.25.6, and the outputs are what the programs printed.

What the game does

The computer picks a whole number from 1 to 100. The player has seven tries. After each guess the game says "Too low" or "Too high", and the round ends when the player is right, runs out of tries, or closes the input with Ctrl-D. A line that is not a number gets an error message and does not use up a try, because losing a turn to a typo is a bad rule.

Those rules fit in one function. The design decision that matters is what that function receives. A first draft usually calls fmt.Scan and fmt.Println directly, and that program can only be tested by a human typing. Instead, give the function the places it reads from and writes to:

Go
func Play(in io.Reader, out io.Writer, secret, tries int) Result

io.Reader and io.Writer are the two small interfaces behind almost all input and output in Go (interfaces explained covers why small interfaces are the point). The real program passes os.Stdin and os.Stdout. A test passes a strings.Reader it built from a string and a bytes.Buffer it can inspect afterwards. The function cannot tell the difference, and neither needs a mocking library.

The secret is also a parameter rather than something Play picks. Randomness inside the function would make every test non-deterministic. Putting it in main keeps the rules pure and the dice at the edge.

Set up the module

Create a folder and a module:

Shell
mkdir guess && cd guess
go mod init guess

You will end up with three files: game.go for the rules, main.go for the command line, and game_test.go for the tests. All three are in package main, which is simplest for a program this size. In a bigger project the rules would move to their own package (packages and exported names).

Write the rules

Start with the result type. A round can end three ways, and an enumerated type says so better than a bare int or a pair of booleans. Go's iota numbers the constants (the iota article):

game.goGo
package main

import (
	"bufio"
	"fmt"
	"io"
	"strconv"
	"strings"
)

// Result is how a round ended.
type Result int

const (
	Won Result = iota
	Lost
	Quit
)

// Play runs one round. It reads one guess per line from in and writes
// everything the player sees to out.
func Play(in io.Reader, out io.Writer, secret, tries int) Result {
	sc := bufio.NewScanner(in)
	for left := tries; left > 0; {
		fmt.Fprintf(out, "Guess (%d left): ", left)
		if !sc.Scan() {
			fmt.Fprintln(out)
			return Quit
		}
		n, err := strconv.Atoi(strings.TrimSpace(sc.Text()))
		if err != nil {
			fmt.Fprintln(out, "Please type a whole number.")
			continue
		}
		left--
		switch {
		case n < secret:
			fmt.Fprintln(out, "Too low.")
		case n > secret:
			fmt.Fprintln(out, "Too high.")
		default:
			fmt.Fprintf(out, "Correct, in %d tries.\n", tries-left)
			return Won
		}
	}
	fmt.Fprintf(out, "Out of tries. It was %d.\n", secret)
	return Lost
}

A few details are worth slowing down for, since each is a small Go habit.

The loop has no post statement: for left := tries; left > 0; { declares a counter and a condition and leaves the decrement to the body. That is deliberate. The counter should drop only for a real guess, and putting left-- after the number is parsed, and not in the loop header, is what makes a typo free. continue jumps back to the top without touching it.

bufio.Scanner reads input a line at a time, and Scan returns false at end of input or on an error. A player who presses Ctrl-D closes standard input, Scan returns false, and the function returns Quit instead of looping forever on an empty read. Forgetting that case is the classic bug in this kind of program, and it only shows up when someone closes the stream. Reading user input in Go compares the scanner with fmt.Scan and bufio.Reader.

strconv.Atoi converts the text and returns an error for anything that is not a base-10 integer, including an empty line, so one call handles all the bad input. The error is a normal return value (multiple return values), and the program does not stop on it: it prints a message and asks again. strings.TrimSpace removes the stray carriage return that a Windows terminal or a pasted line can leave on the end.

The switch with no expression is a tidy way to write an if-else chain whose cases are conditions (the switch statement). The default branch is the winning one, which reads slightly backwards until you notice that "neither too low nor too high" is exactly "equal".

Write main

main owns everything environmental: flags, randomness and the exit code. The version that is easiest to test keeps main to one line and moves the work into a function that receives its environment as arguments, the same idea as Play:

main.goGo
package main

import (
	"flag"
	"fmt"
	"io"
	"math/rand/v2"
	"os"
)

func main() {
	os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}

// run returns the process exit code: 0 won, 1 lost, 2 input closed, 64 bad usage.
func run(args []string, in io.Reader, out, errOut io.Writer) int {
	fs := flag.NewFlagSet("guess", flag.ContinueOnError)
	fs.SetOutput(errOut)
	secret := fs.Int("secret", 0, "fixed secret from 1 to 100, for demos")
	tries := fs.Int("tries", 7, "number of guesses")
	if err := fs.Parse(args); err != nil {
		return 64
	}
	if *tries < 1 {
		fmt.Fprintln(errOut, "tries must be at least 1")
		return 64
	}

	n := *secret
	if n == 0 {
		n = rand.IntN(100) + 1
	}
	switch Play(in, out, n, *tries) {
	case Lost:
		return 1
	case Quit:
		return 2
	}
	return 0
}

rand.IntN(100) returns a number from 0 to 99, so the program adds one. It comes from math/rand/v2, which needs no seeding: the global generator is seeded randomly, as random numbers in Go explains, and that is what you want for a game. The -secret flag exists so a demo and a script can fix the number. A real player never needs it, and a flag is a lighter hook than an environment variable or a build tag.

Exit codes are the other detail. A won round exits 0, a lost one exits 1, a closed stream exits 2 and a bad flag exits 64, the EX_USAGE value from BSD's sysexits.h, so a shell script can tell the outcomes apart, which is what a command line program should do. Using a flag.NewFlagSet with ContinueOnError instead of the global flag.Parse is the part that makes run testable: the global version calls os.Exit itself on a bad flag, and a test cannot survive that. Command line arguments covers the flag side. Note that main is the only function that calls os.Exit. A deep function that exits cannot be tested, and it skips any deferred cleanup above it.

Play it

Rather than type, pipe in a script. These guesses include a typo, which should not cost a try:

Shell
printf '50\nabc\n95\n88\n' | go run . -secret 88
echo "exit status: $?"
Text
Guess (7 left): Too low.
Guess (6 left): Please type a whole number.
Guess (6 left): Too high.
Guess (5 left): Correct, in 3 tries.
exit status: 0

The prompts and the answers run together because piped input has no echo, but the sequence is right: 50 is too low, abc is rejected with the counter still at six, 95 is too high, and 88 wins in three tries. Running go run . with no flag plays a real game with a secret you do not know.

The other two exit codes are just as easy to see, with one catch: go run reports a non-zero exit from the program as exit status N and then exits 1 itself, so the real code is lost. Build a binary first. A losing round and a closed input stream, in a shell:

Shell
go build -o guess .
printf '1\n2\n' | ./guess -secret 50 -tries 2; echo "lost: $?"
printf '10\n' | ./guess -secret 50; echo "quit: $?"
Text
Guess (2 left): Too low.
Guess (1 left): Too low.
Out of tries. It was 50.
lost: 1
Guess (7 left): Too low.
Guess (6 left): 
quit: 2

The first run uses both tries and prints the secret, and the shell reports status 1. The second sends one guess and then end of input, so the game prints a newline, leaves the prompt tidy, and reports 2. Status 2 is the case a script cares about most, since it means nobody was there to answer. Scripts that wrap the game, for example to run a thousand simulated rounds with a bot, can branch on these codes without parsing the text, and that habit of treating the exit code as part of the interface is worth building in every command line tool you write.

Test the rules

The payoff of the io.Reader and io.Writer design is that a test is a table of inputs and expected outcomes. Go's convention is a _test.go file in the same package and functions named TestXxx that take a *testing.T:

game_test.goGo
package main

import (
	"bytes"
	"fmt"
	"strings"
	"testing"
)

func TestPlay(t *testing.T) {
	tests := []struct {
		name   string
		input  string
		secret int
		tries  int
		want   Result
		prints string
	}{
		{"wins first try", "42\n", 42, 7, Won, "Correct, in 1 tries."},
		{"too low then right", "10\n42\n", 42, 7, Won, "Too low."},
		{"too high then right", "90\n42\n", 42, 7, Won, "Too high."},
		{"runs out of tries", "1\n2\n3\n", 42, 3, Lost, "Out of tries. It was 42."},
		{"typo is free", "x\n\n42\n", 42, 1, Won, "Please type a whole number."},
		{"closed input quits", "10\n", 42, 7, Quit, "Guess (6 left): "},
		{"spaces are trimmed", "  42 \r\n", 42, 7, Won, "Correct"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			var out bytes.Buffer
			got := Play(strings.NewReader(tc.input), &out, tc.secret, tc.tries)
			if got != tc.want {
				t.Errorf("result = %v, want %v", got, tc.want)
			}
			if !strings.Contains(out.String(), tc.prints) {
				t.Errorf("output %q does not contain %q", out.String(), tc.prints)
			}
		})
	}
}

// bisect returns the guesses a player who always picks the middle of the
// remaining range would type for a given secret.
func bisect(secret int) string {
	var b strings.Builder
	lo, hi := 1, 100
	for lo <= hi {
		mid := (lo + hi) / 2
		fmt.Fprintln(&b, mid)
		switch {
		case mid < secret:
			lo = mid + 1
		case mid > secret:
			hi = mid - 1
		default:
			return b.String()
		}
	}
	return b.String()
}

func TestSevenTriesAlwaysEnough(t *testing.T) {
	for secret := 1; secret <= 100; secret++ {
		var out bytes.Buffer
		if got := Play(strings.NewReader(bisect(secret)), &out, secret, 7); got != Won {
			t.Fatalf("secret %d: bisection lost\n%s", secret, out.String())
		}
	}
}

Run it with the race detector, and with -cover to see how much of the code the tests reach:

Shell
go test -race -cover -count=1 .
Text
ok  	example.test/lab	1.239s	coverage: 97.1% of statements

The test table is the main idea to take away. Adding a new rule later, such as a difficulty level, is one more row. t.Run gives each row its own name in the output, so a failure says which case broke, and the closure captures tc safely because Go 1.22 gives each iteration its own variable (the loop variable change). The Quit row proves the end-of-input path, which a human would almost never exercise. The typo is free row has a tries of 1 on purpose: if a typo cost a try, the game would be lost before 42 arrived, so the test fails for exactly the bug it names.

The same technique works one level up. run takes arguments and streams, so the command line is testable too:

main_test.goGo
package main

import (
	"bytes"
	"strings"
	"testing"
)

func TestRun(t *testing.T) {
	tests := []struct {
		name   string
		args   []string
		input  string
		want   int
		stderr string
	}{
		{"won", []string{"-secret", "7"}, "7\n", 0, ""},
		{"lost", []string{"-secret", "7", "-tries", "1"}, "1\n", 1, ""},
		{"input closed", []string{"-secret", "7"}, "", 2, ""},
		{"random secret", nil, "", 2, ""},
		{"unknown flag", []string{"-nope"}, "", 64, "flag provided but not defined"},
		{"zero tries", []string{"-tries", "0"}, "", 64, "at least 1"},
	}
	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			var out, errOut bytes.Buffer
			got := run(tc.args, strings.NewReader(tc.input), &out, &errOut)
			if got != tc.want {
				t.Errorf("exit = %d, want %d", got, tc.want)
			}
			if !strings.Contains(errOut.String(), tc.stderr) {
				t.Errorf("stderr %q does not contain %q", errOut.String(), tc.stderr)
			}
		})
	}
}
Text
=== RUN   TestPlay
=== RUN   TestPlay/wins_first_try
=== RUN   TestPlay/too_low_then_right
=== RUN   TestPlay/too_high_then_right
=== RUN   TestPlay/runs_out_of_tries
=== RUN   TestPlay/typo_is_free
=== RUN   TestPlay/closed_input_quits
=== RUN   TestPlay/spaces_are_trimmed
--- PASS: TestPlay (0.00s)
    --- PASS: TestPlay/wins_first_try (0.00s)
    --- PASS: TestPlay/too_low_then_right (0.00s)
    --- PASS: TestPlay/too_high_then_right (0.00s)
    --- PASS: TestPlay/runs_out_of_tries (0.00s)
    --- PASS: TestPlay/typo_is_free (0.00s)
    --- PASS: TestPlay/closed_input_quits (0.00s)
    --- PASS: TestPlay/spaces_are_trimmed (0.00s)
=== RUN   TestSevenTriesAlwaysEnough
--- PASS: TestSevenTriesAlwaysEnough (0.00s)
=== RUN   TestRun
=== RUN   TestRun/won
=== RUN   TestRun/lost
=== RUN   TestRun/input_closed
=== RUN   TestRun/random_secret
=== RUN   TestRun/unknown_flag
=== RUN   TestRun/zero_tries
--- PASS: TestRun (0.00s)
    --- PASS: TestRun/won (0.00s)
    --- PASS: TestRun/lost (0.00s)
    --- PASS: TestRun/input_closed (0.00s)
    --- PASS: TestRun/random_secret (0.00s)
    --- PASS: TestRun/unknown_flag (0.00s)
    --- PASS: TestRun/zero_tries (0.00s)
PASS
ok  	example.test/lab	1.199s

A property check from arithmetic

The second test is different in kind. Instead of listing examples it checks a claim about all of them: with seven tries a bisecting player wins for every secret from 1 to 100. The claim follows from arithmetic. Each guess halves the range of candidates, and 2^7 is 128, which is more than 100, so seven halvings are enough. With six tries the claim would fail, because 2^6 is 64. You can confirm that by changing the 7 in the test to 6 and watching a secret in the 90s fail. The helper that generates the guesses is five lines, and the loop proves a statement that no handful of examples could.

It also tells you something about the design of the game, which is a good thing for a test to do: seven tries is fair, because a good strategy always wins and a careless one usually does not. A harder game would use fewer tries or a bigger range, and the same test, with the numbers changed, would tell you how hard.

Reading the coverage number

-cover prints one percentage, which is a summary and not a verdict. To see where the rest is, write a profile and ask for a per-function report:

Shell
go test -coverprofile=c.out -count=1 . > /dev/null
go tool cover -func=c.out
Text
example.test/lab/game.go:22:	Play		100.0%
example.test/lab/main.go:11:	main		0.0%
example.test/lab/main.go:16:	run		100.0%
total:				(statements)	97.1%

The report shows Play fully covered and run just short of it before the random secret row was added to the table, because the branch that picks a random number only runs when no -secret is given. With that row in place only main is at zero. It is the one function that cannot be tested in process, because it calls os.Exit. That is a good place for the uncovered code to be: one line, with no logic in it. Coverage is most useful as a pointer to branches you forgot, such as the Quit path, and least useful as a target. A suite at 100% can still assert nothing, and the test for the bisection claim above contributes no new lines at all while proving something the rows could not. go tool cover -html=c.out opens the same data as a page with covered lines in green, which is the quickest way to find an untested else.

Three mistakes this design avoids

The first draft of this game, in most hands, differs from the one above in three ways, and each is worth recognising.

Reading with fmt.Scan. fmt.Scan(&n) looks shorter, and it works until the player types a word. It then fails without consuming the bad token, so a loop around it spins forever printing errors unless you also drain the input. It also gives you no clean way to tell "the player typed text" from "there is no more input". Reading a line with bufio.Scanner and converting it yourself separates the two cases and costs three more lines.

Seeding the generator with the clock. Older tutorials call rand.Seed(time.Now().UnixNano()), a habit from math/rand before Go 1.20 when the global generator started with a fixed seed and every run played the same game. Since Go 1.20 the global source is randomly seeded, and math/rand/v2 removed the global Seed function altogether. Code that still calls it will not compile against v2, and against v1 it is deprecated.

Putting os.Exit and fmt.Println inside the rules. The rule functions end up impossible to call from a test or a second front end, such as a web page or a chat bot that wants the same game. Passing the reader and writer in costs two parameters and buys both.

Difficulty from arithmetic

The bisection test suggests a feature: pick the number of tries from the size of the range, so that a perfect player always has exactly enough. With n possible secrets, the smallest number of guesses that always suffices is the number of bits needed to write n, because each guess can at best halve the candidates. The math/bits package computes that:

levels.goGo
package main

import "math/bits"

// TriesFor returns the fewest guesses that always suffice for a secret in 1..max.
func TriesFor(max int) int {
	return bits.Len(uint(max))
}
levels_test.goGo
package main

import "testing"

func TestTriesFor(t *testing.T) {
	for max, want := range map[int]int{1: 1, 2: 2, 3: 2, 4: 3, 100: 7, 1000: 10, 1_000_000: 20} {
		if got := TriesFor(max); got != want {
			t.Errorf("TriesFor(%d) = %d, want %d", max, got, want)
		}
	}
}
Text
=== RUN   TestTriesFor
--- PASS: TestTriesFor (0.00s)
PASS
ok  	example.test/lab	1.264s

The case of 100 gives 7, matching what the earlier test proved by brute force, and a million possible secrets needs only twenty guesses. That is the whole appeal of binary search, and it is a nice moment when the arithmetic and the test agree. Wiring it in is a two-line change: add a -max flag, pass it to Play as the upper bound, and default -tries to TriesFor(max). The tests you already have tell you whether it still works.

Giving hints

A small feature shows how the structure pays off. A "warmer or colder" hint compares the distance of this guess to the secret with the distance of the previous one. That is a pure function of three numbers, so it belongs in its own file with its own test, and Play calls it:

hint.goGo
package main

// Hint says whether guess is closer to secret than the previous guess was.
// A previous of 0 means there was no earlier guess.
func Hint(previous, guess, secret int) string {
	if previous == 0 {
		return ""
	}
	switch before, now := abs(previous-secret), abs(guess-secret); {
	case now < before:
		return "Warmer."
	case now > before:
		return "Colder."
	}
	return "Same distance."
}

func abs(n int) int {
	if n < 0 {
		return -n
	}
	return n
}
hint_test.goGo
package main

import "testing"

func TestHint(t *testing.T) {
	tests := []struct {
		previous, guess, secret int
		want                    string
	}{
		{0, 50, 80, ""},
		{50, 70, 80, "Warmer."},
		{70, 40, 80, "Colder."},
		{60, 100, 80, "Same distance."},
	}
	for _, tc := range tests {
		if got := Hint(tc.previous, tc.guess, tc.secret); got != tc.want {
			t.Errorf("Hint(%d, %d, %d) = %q, want %q", tc.previous, tc.guess, tc.secret, got, tc.want)
		}
	}
}
Text
=== RUN   TestHint
--- PASS: TestHint (0.00s)
PASS
ok  	example.test/lab	1.248s

The switch header declares two variables and then has an empty condition, so each case is a boolean over them, a form that keeps the temporary values out of the surrounding function. Play would remember previous in a local variable, update it after every valid guess, and print the hint after "Too low" or "Too high". Nothing about that touches the reader, the writer or the exit codes, which is the reason to split rules from I/O in the first place. The same function would serve a web version of the game without a change, and the existing table tests in TestPlay would only need new rows for the extra line of output. A feature that is a pure function is the cheapest kind to test and the easiest kind to change.

Where to take it next

Each of these is a good exercise and each one touches a new part of the language:

  • Hints. Tell the player whether they are getting warmer, which needs the previous distance kept in the loop.
  • Difficulty levels. Read -max as well as -tries, then derive the tries from the range with math/bits.Len.
  • A high score file. Write the best try count to a file with os.WriteFile, which introduces reading and writing files and the error paths that come with them.
  • Two players. Make the input a second io.Reader, and notice that Play already supports it.

If you want a project with more moving parts, the todo list built with only the standard library is the next one on the path, and exercises that teach the language lists places to practice. The official Tour of Go and the testing package documentation are the two references this project leaned on.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.