Chapter 11: Saving, loading and determinism
This chapter:
- Write and read the high score to a small file with
core:os. - Make loading tolerant of a missing or corrupt file, never crash on a first run.
- Lock down the seeded-randomness story so a seed reproduces a course exactly.
Where we left off: a complete, sounding game that forgets your best the moment you quit.
11.1 What to persist, and where
We persist one number: the best score. The simplest place is a text file beside the binary:
// save.odin
SAVE_FILE :: "skoll_best.txt"
Gotcha: beside the binary is fine for a book, not for a shipped game. A real desktop game writes to the per-user config directory:
%APPDATA%on Windows,~/Library/Application Supporton macOS,$XDG_CONFIG_HOME(or~/.config) on Linux. Writing next to the executable can fail if it's installed somewhere read-only, and it doesn't separate two users. We keep it simple here so the lesson is the file I/O and the tolerant load, not path wrangling, but know the right answer. (This is the desktop counterpart of Yo Odin's browserlocalStorage: same idea, different store.)
11.2 Reading and writing with core:os
Two calls do the work. Note the API shapes for this Odin version: they return an Error, not a bool.
// save.odin
import "core:os"
import "core:strconv"
load_best :: proc() -> int {
data, err := os.read_entire_file_from_path(SAVE_FILE, context.allocator)
if err != nil do return 0 // no file yet, first run
defer delete(data)
n, pok := strconv.parse_int(string(data))
if !pok do return 0 // garbled, treat as unset
return n
}
save_best :: proc(v: int) {
buf: [32]u8
s := strconv.write_int(buf[:], i64(v), 10)
_ = os.write_entire_file(SAVE_FILE, transmute([]u8)s) // best-effort; ignore write errors
}
Small things worth seeing:
string(data): the file's bytes viewed as a string, thenstrconv.parse_intturns it into an integer with a "did it parse" bool. A flat, unversioned format: one number, no schema.strconv.write_int(buf[:], i64(v), 10): formats the integer into a stack buffer (no heap), returning the used slice.transmute([]u8)sreinterprets that string slice as bytes for the write._ =:write_entire_filereturns anErrorthe compiler insists you handle;_ =says "I'm deliberately ignoring it" (a failed high-score write shouldn't crash the game).
11.3 Tolerant loading
The load path never assumes the file exists or is valid:
- No file (
err != nil) โ return 0. This is the first-ever run. - Unparseable contents (
!pok) โ return 0. A truncated or hand-edited file shouldn't crash you.
// main.odin, at boot
g.best = load_best()
Why every load must tolerate absence. The first time anyone runs your game, there is no save file: that's the normal case, not an error. A loader that crashes or refuses to start on a missing file fails exactly the people trying it for the first time. Return the caller's sensible default (0) and carry on.
save_bestis called fromkill_playerwhen you beat your best, so the file appears after the first good run and is read on the next launch.
11.4 Determinism, properly
Chapter 6 introduced the seeded generator; here's the full story. The game owns one random state and reseeds it at the start of each run:
// game.odin
SEED_FIXED :: 0 // set non-zero to force one fixed, reproducible course
start_run :: proc() {
seed: u64 = SEED_FIXED
if seed == 0 {
seed = u64(rl.GetTime() * 1_000_000) + g.run_seq * 2654435761
}
g.run_seq += 1
g.rng = rand.create_u64(seed)
// ... clear entities, reset player ...
context.random_generator = rand.default_random_generator(&g.rng)
advance_generation()
}
- One state, threaded through
context.context.random_generator = rand.default_random_generator(&g.rng)at the top ofstart_runandupdate_gamemakesg.rngthe generator for everything those procedures call. Everyrand.*in generation draws from it, in order, so the sequence is fully determined by the seed. - A fresh seed each run, or a fixed one for testing. By default the seed comes from the clock (plus a per-run counter so two runs in the same millisecond still differ). Set
SEED_FIXEDto any non-zero value and every run plays the identical course: invaluable for reproducing a bug or tuning a specific stretch. - Nothing in the simulation reads the wall clock. Movement uses
dt, notGetTime(); generation usesg.rng, notGetRandomValue. Given the same seed and the same inputs, the run unfolds the same way every time.
Why determinism is worth the discipline. Reproducibility is what makes a bug findable ("seed 42, about 3000px in, the gap is unclearable") and a comparison fair (two players, same seed, same course). It costs almost nothing here, one owned state and one
contextline, and it's a habit worth carrying into every game you write.
Checkpoint
Your best score now survives quitting and relaunching, written the moment you beat it and read tolerantly at boot. Courses are deterministic: a fixed seed reproduces an identical run, and nothing in the simulation sneaks a peek at the wall clock. The game is content-complete. All that's left is to turn it into something you can hand to someone: the next, final chapter.
Exercises
- Set
SEED_FIXEDto42, play twice, and confirm the course is byte-for-byte the same. Reset it. - Corrupt
skoll_best.txtby hand (put "hello" in it). Confirm the game still starts and treats the best as 0. - Persist a second value, say, the furthest distance, as a second line, and parse both on load.
- Move the save to the correct per-OS config directory for your platform (see the Gotcha in ยง11.1).
Further reading
- Odin
core:os(read_entire_file_from_path,write_entire_file): https://pkg.odin-lang.org/core/os/ - Odin
core:strconv(parse_int,write_int): https://pkg.odin-lang.org/core/strconv/ - Odin
core:math/rand(create_u64,default_random_generator): https://pkg.odin-lang.org/core/math/rand/