Odin and Me ยท Chapter 6

Chapter 6: Structs, enums and a number-guessing game

This chapter:

  • Define a struct to hold a program's state, instead of a handful of loose variables.
  • Use an enum and switch to model a small, fixed set of choices.
  • Generate a seeded random number with core:math/rand.
  • Pass a pointer to a struct so a procedure can update it in place.
  • Write guess.odin, a complete number-guessing game with difficulty levels.

Where we left off: Chapter 5 was pure loops and text. This chapter introduces the other big building block: structured data and how to use it to do interesting things.


6.1 A struct for game state

A guessing game needs to remember three things while it's being played: the number it picked, how many guesses have been taken, and whether the player has won yet. Loose variables would work, but a struct keeps them together as one thing that gets passed around as a unit:

GameState :: struct {
	target:      int,
	max_guesses: int,
	guesses:     int,
	won:         bool,
}

A freshly declared GameState starts at its zero value: every field set to its type's default (0 for int, false for bool), with no constructor to write or call:

state: GameState
fmt.println(state.guesses, state.won)   // 0 false

Odin note: zero values are a design feature. Odin guarantees every type has a sensible zero value, and a freshly declared variable always starts there. A GameState you haven't filled in yet already says "no guesses taken, not won" correctly, without you writing any initialisation code for it.

6.2 An enum for difficulty

Three difficulties, each meaning a different number range and guess limit: a small, fixed set of named choices is exactly what an enum is for:

Difficulty :: enum {
	Easy,
	Medium,
	Hard,
}

switch over the enum picks the range and guess limit for each one, returning all three as multiple values (similar to Chapter 2's divmod pattern):

difficulty_range :: proc(d: Difficulty) -> (lo, hi, max_guesses: int) {
	switch d {
	case .Easy:
		return 1, 20, 8
	case .Medium:
		return 1, 100, 7
	case .Hard:
		return 1, 1000, 10
	}
	return 1, 100, 7
}

Odin note: .Easy, not Difficulty.Easy, inside the switch. Once the compiler knows you're switching on a Difficulty, it lets you write just the enum's value with a leading dot and infers the type. You'll see this shortened form again wherever a value's type is already obvious from context.

This one small procedure is the seed of an idiom you'll meet again and again in bigger Odin programs: a single table (here, a switch; in a larger project, often a literal array) that's the one place a kind-of-thing maps to its stats, so adding a new difficulty later means changing this one procedure, not hunting through the whole file for every place 20 or 8 was hard-coded.

6.3 A random target, seeded

core:math/rand needs a generator before it will give you numbers: Odin doesn't reach for hidden global state on its own. Create one from a seed, and install it as the generator the rest of the program's random calls will use:

import "core:math/rand"

state := rand.create(42)
context.random_generator = rand.default_random_generator(&state)

n := rand.int_max(20) + 1   // 1 .. 20, inclusive

rand.int_max(n) returns a value from 0 up to (but not including) n; adding 1 shifts that into the 1 ..= n range a guessing game actually wants. rand.create(42) builds a Default_Random_State seeded with 42 specifically: the same seed always produces the same sequence of "random" numbers, which is why my examples below show you exact output that you can reproduce yourself.

Why seed with a fixed number instead of the time of day? A game you ship should feel different every time you play it, which argues for seeding from something that changes: Chapter 11 covers reseeding from rand.int63() at the point a real round starts. But while you're writing and testing a program that depends on randomness, a fixed seed is what makes "did my change break anything" answerable: run the same seed twice, get the same numbers twice, and any difference in output is down to your code, not randomness. For debugging purposes, this trick is worth remembering.

6.4 The guessing loop

take_guess is where pointers show up: it takes ^GameState, a pointer to the caller's struct, so it can update guesses and won in place rather than handing back a new struct every time:

take_guess :: proc(state: ^GameState, guess: int) {
	state.guesses += 1
	if guess == state.target {
		state.won = true
	}
}

Odin note: state.guesses, not state^.guesses. Odin lets you write . straight through a pointer to a struct; it dereferences automatically. You'll see the same convenience in every pointer-to-struct procedure we write, including some of Muninn's code later on.

Pointer or return value? take_guess could instead have been take_guess :: proc(state: GameState, guess: int) -> GameState, taking the struct by value and returning an updated copy for the caller to reassign. Both are legitimate Odin; the pointer version is the natural fit here because play_round's loop needs to keep checking the same state's won and guesses after every call, and a pointer means every call is updating the one state everybody's looking at, rather than each caller needing to remember to reassign a returned copy.

Wrap that in a loop that keeps asking until the player wins or runs out of guesses, reusing Chapter 3's read-a-line-and-parse pattern:

play_round :: proc(scanner: ^bufio.Scanner, d: Difficulty) -> (guesses: int, won: bool) {
	state := new_game(d)
	lo, hi, _ := difficulty_range(d)
	fmt.printfln("I'm thinking of a number between %d and %d. You have %d guesses.", lo, hi, state.max_guesses)

	for !state.won && state.guesses < state.max_guesses {
		fmt.printf("Guess %d/%d: ", state.guesses+1, state.max_guesses)
		line, ok := read_line(scanner)
		if !ok {
			os.exit(1)
		}
		guess, parsed := strconv.parse_int(line)
		if !parsed {
			fmt.println("Please enter a whole number.")
			continue
		}
		take_guess(&state, guess)
		if state.won {
			fmt.printfln("Correct! You got it in %d guesses.", state.guesses)
		} else if guess < state.target {
			fmt.println("Higher.")
		} else {
			fmt.println("Lower.")
		}
	}

	if !state.won {
		fmt.printfln("Out of guesses. The number was %d.", state.target)
	}

	return state.guesses, state.won
}

An invalid guess (parsed == false) hits continue before take_guess is ever called: a bad line doesn't cost the player a guess, only a real, parseable one does. new_game builds the starting state:

new_game :: proc(d: Difficulty) -> GameState {
	lo, hi, max_guesses := difficulty_range(d)
	return GameState{
		target = rand.int_max(hi-lo+1) + lo,
		max_guesses = max_guesses,
	}
}

GameState{target = ..., max_guesses = ...} is a struct literal with named fields: guesses and won are left out entirely and simply take their zero values, 0 and false, exactly the ones ยง6.1 promised you'd get for free.

6.5 The complete program

// guess.odin
package main

import "core:bufio"
import "core:fmt"
import "core:math/rand"
import "core:os"
import "core:strconv"
import "core:strings"

Difficulty :: enum {
	Easy,
	Medium,
	Hard,
}

GameState :: struct {
	target:      int,
	max_guesses: int,
	guesses:     int,
	won:         bool,
}

difficulty_range :: proc(d: Difficulty) -> (lo, hi, max_guesses: int) {
	switch d {
	case .Easy:
		return 1, 20, 8
	case .Medium:
		return 1, 100, 7
	case .Hard:
		return 1, 1000, 10
	}
	return 1, 100, 7
}

new_game :: proc(d: Difficulty) -> GameState {
	lo, hi, max_guesses := difficulty_range(d)
	return GameState{
		target = rand.int_max(hi-lo+1) + lo,
		max_guesses = max_guesses,
	}
}

take_guess :: proc(state: ^GameState, guess: int) {
	state.guesses += 1
	if guess == state.target {
		state.won = true
	}
}

read_line :: proc(scanner: ^bufio.Scanner) -> (line: string, ok: bool) {
	if !bufio.scanner_scan(scanner) {
		return "", false
	}
	return strings.trim_space(bufio.scanner_text(scanner)), true
}

play_round :: proc(scanner: ^bufio.Scanner, d: Difficulty) -> (guesses: int, won: bool) {
	state := new_game(d)
	lo, hi, _ := difficulty_range(d)
	fmt.printfln("I'm thinking of a number between %d and %d. You have %d guesses.", lo, hi, state.max_guesses)

	for !state.won && state.guesses < state.max_guesses {
		fmt.printf("Guess %d/%d: ", state.guesses+1, state.max_guesses)
		line, ok := read_line(scanner)
		if !ok {
			os.exit(1)
		}
		guess, parsed := strconv.parse_int(line)
		if !parsed {
			fmt.println("Please enter a whole number.")
			continue
		}
		take_guess(&state, guess)
		if state.won {
			fmt.printfln("Correct! You got it in %d guesses.", state.guesses)
		} else if guess < state.target {
			fmt.println("Higher.")
		} else {
			fmt.println("Lower.")
		}
	}

	if !state.won {
		fmt.printfln("Out of guesses. The number was %d.", state.target)
	}

	return state.guesses, state.won
}

main :: proc() {
	state := rand.create(42)
	context.random_generator = rand.default_random_generator(&state)

	scanner: bufio.Scanner
	bufio.scanner_init(&scanner, os.to_reader(os.stdin))
	defer bufio.scanner_destroy(&scanner)

	play_round(&scanner, .Easy)
}

Play it (the seed above always picks 15 for the first Easy round, so this exact transcript is reproducible):

$ odin run guess.odin -file
I'm thinking of a number between 1 and 20. You have 8 guesses.
Guess 1/8: 10
Higher.
Guess 2/8: 20
Lower.
Guess 3/8: banana
Please enter a whole number.
Guess 3/8: 15
Correct! You got it in 3 guesses.

6.6 A second pass: best score across rounds

Track the fewest guesses it's taken to win, across as many rounds as the player wants to play in one sitting, a small taste of the "did this run outlive a single round" persistence Chapter 8 adds properly, across whole program runs rather than just rounds within one.

main :: proc() {
	state := rand.create(42)
	context.random_generator = rand.default_random_generator(&state)

	scanner: bufio.Scanner
	bufio.scanner_init(&scanner, os.to_reader(os.stdin))
	defer bufio.scanner_destroy(&scanner)

	best_guesses := max(int)
	rounds := 0

	for {
		rounds += 1
		guesses, won := play_round(&scanner, .Easy)
		if won && guesses < best_guesses {
			best_guesses = guesses
			fmt.printfln("New best: %d guesses.", best_guesses)
		}

		fmt.print("Play again? (y/n): ")
		line, ok := read_line(&scanner)
		if !ok || line != "y" {
			break
		}
	}

	if best_guesses < max(int) {
		fmt.printfln("Best across %d round(s): %d guesses.", rounds, best_guesses)
	} else {
		fmt.println("No rounds won this session.")
	}
}
$ odin run guess.odin -file
I'm thinking of a number between 1 and 20. You have 8 guesses.
Guess 1/8: 10
Higher.
Guess 2/8: 20
Lower.
Guess 3/8: 17
Lower.
Guess 4/8: 16
Lower.
Guess 5/8: 15
Correct! You got it in 5 guesses.
New best: 5 guesses.
Play again? (y/n): y
I'm thinking of a number between 1 and 20. You have 8 guesses.
Guess 1/8: 1
Correct! You got it in 1 guesses.
New best: 1 guesses.
Play again? (y/n): n
Best across 2 round(s): 1 guesses.

best_guesses := max(int) starts the tracker at the largest possible int, so that the very first win is guaranteed to beat it, a common trick for "smallest so far" tracking that avoids a separate "have we won at least once yet" flag.

Checkpoint

guess.odin runs a complete number-guessing game with difficulty levels, a struct holding the round's state, and a best-score tracker across as many rounds as the player wants in one session. You've written and used a struct, an enum, a seeded random generator, and a pointer-taking procedure that mutates its caller's state.

Exercises

  1. Add a Medium and Hard menu choice before the first round starts (prompt for 1/2/3, switch the answer to a Difficulty, default to Easy for anything else). Reuse difficulty_range unchanged.
  2. play_round's guess counter only increases on a parseable guess. What would change about the feel of the game if an unparseable line ("banana") cost a guess too? Make that change, play a round, and decide which behaviour you actually prefer.
  3. Add a "you're getting warmer/colder" hint: track the previous guess's distance from the target (abs(guess - state.target)) and compare it to the current guess's distance, printing an extra line when the player is closer than their last guess. (Solution sketch: you'll need somewhere to remember the previous distance between calls to take_guess: either a new field on GameState or a local variable in play_round's loop; either works, but think about which one keeps take_guess's job "record this guess", not "manage hint state" too.)
  4. new_game calls rand.int_max(hi-lo+1) + lo. What would rand.int_max(hi-lo) + lo give you instead: does it still cover every number from lo to hi, or does it quietly exclude one end? Work it out on paper for lo = 1, hi = 20 before you try it.
  5. best_guesses := max(int) is one way to seed a "smallest so far" tracker. Rewrite main to use a best_guesses: int and a separate has_best: bool instead, updating both together. Which version do you find easier to read, and why might a real codebase prefer one over the other?

Further reading