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.

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:
func Play(in io.Reader, out io.Writer, secret, tries int) Resultio.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:
mkdir guess && cd guess
go mod init guessYou 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):
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:
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:
printf '50\nabc\n95\n88\n' | go run . -secret 88
echo "exit status: $?"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: 0The 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:
go build -o guess .
printf '1\n2\n' | ./guess -secret 50 -tries 2; echo "lost: $?"
printf '10\n' | ./guess -secret 50; echo "quit: $?"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: 2The 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:
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:
go test -race -cover -count=1 .ok example.test/lab 1.239s coverage: 97.1% of statementsThe 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:
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)
}
})
}
}=== 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.199sA 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:
go test -coverprofile=c.out -count=1 . > /dev/null
go tool cover -func=c.outexample.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:
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))
}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)
}
}
}=== RUN TestTriesFor
--- PASS: TestTriesFor (0.00s)
PASS
ok example.test/lab 1.264sThe 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:
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
}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)
}
}
}=== RUN TestHint
--- PASS: TestHint (0.00s)
PASS
ok example.test/lab 1.248sThe 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
-maxas well as-tries, then derive the tries from the range withmath/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 thatPlayalready 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.


