The World Vol 1 · A World That Ticks
ch 10 / 105
Chapter 10

Prove It Twice

Same seed, same bytes

Six hundred ticks now produces tens of thousands of bytes of history, and none of them are being checked. The rule is exact: same seed, same tick count, same bytes: two runs of this world produce event logs that are equal byte for byte, and a single differing byte is a bug in the simulation, never noise.

Back when the world was 96 cells of rock and water, proving that a seed determined it took one line: generate the grid twice, compare the two renderings with ==, print true. That proof was honest and it is now badly out of date.

The world has entities with identities, a tick counter, laws that draw from a seeded stream every tick, moves that get refused by the ground, a spring that floods a cell every forty ticks, and a log recording all of it one line at a time.

The gap matters more than it looks. Suppose a walker ends up standing in water, and the log says it happened at tick 4,102 of seed 5. In a deterministic world you rerun seed 5, stop at tick 4,102, and stare at the same walker in the same puddle as often as you need.

Otherwise you have a story about something that happened once to somebody's process. No tolerance, no "mostly the same", no fields exempted for being unimportant. The test has to catch one differing byte.

The _test.go file

Go has testing built into the toolchain, so there is no library to choose and nothing to install. Put a file whose name ends in _test.go beside the code it tests; write functions named TestSomething taking one argument of type *testing.T; run go test. Those files are invisible to go build, so test code never ships inside worldd, and because this one declares package sim rather than an external test package it can reach unexported names in the package it is testing.

Start with chapter 4's generator claim, the smallest true thing this test file needs to defend. It fits in nine lines and its job here is to introduce the machinery.

▣ Build · stage 1: the first test file in this book
// internal/sim/gen_test.go
package sim

import "testing"

func TestGenerateRepeatsItself(t *testing.T) {
	first := Generate(12, 8, 5).Render()
	second := Generate(12, 8, 5).Render()

	if first != second {
		t.Fatalf("seed 5 grew two different worlds:\n%s\n%s", first, second)
	}
}
$ go test -v -run TestGenerate ./internal/sim/   (elapsed times measured on the author's machine; yours will differ)
=== RUN   TestGenerateRepeatsItself
--- PASS: TestGenerateRepeatsItself (0.00s)
PASS
ok  	theworld/internal/sim	0.002s
$ go test ./...
?   	theworld/cmd/worldd	[no test files]
ok  	theworld/internal/sim	0.010s

A test reports failure by calling a method on t, and never by returning a value or panicking. t.Fatalf formats a message, marks the test failed, and stops that test function there; its sibling t.Errorf marks the failure and keeps going, for when several independent checks each deserve reporting. Nothing prints on success unless you ask, which is what -v does. The message matters as much as the check: it is written for the version of you who reads it at midnight with no memory of this file, so it says what was compared and shows both sides.

The second command runs every package in the module. cmd/worldd has no tests, and Go says so plainly instead of pretending it passed. The tool is also aggressive about not repeating work: run go test twice without changing anything and the second run answers ok theworld/internal/sim (cached) in no time at all, because the result of a test whose code and inputs are unchanged cannot have changed either. That is a convenience most of the time and a trap when you are chasing something that only fails sometimes, so a chapter about nondeterminism keeps -count=1 within reach.

⚙ Tool · the four go test flags this book uses

-v prints every test as it runs, along with anything t.Logf wrote. -run PATTERN selects test functions by regular expression, so -run TestSameSeed runs one of them. -count=1 disables the result cache and forces a real run. -count=N runs the whole selection N times over, which is how you make an intermittent failure show itself. Full reference: pkg.go.dev/cmd/go#hdr-Testing_flags.

The run moves into sim

The generator was easy to test because it is a function you can call. The world is not: setting it up, spawning entities, opening a log, and running the loop all live in main, and main is the one function no test can call. That is not a testing quirk to work around. It is a design problem the test just exposed, and the fix improves the program on its own merits: everything about what a run is moves into the sim package behind one call, and main keeps what only a command-line program needs, which is flags, signals, and last chapter's goroutine and stop channel.

▣ Build · stage 2: one run, described by a value
// internal/sim/run.go
package sim

import (
	"fmt"
	"time"
)

// Config is everything one run of the world needs to know. Every
// field is a decision the caller makes; nothing here is read from the
// machine.
type Config struct {
	Seed  uint64 // names the world and every draw inside it
	Ticks uint64 // how many ticks to take before stopping
	Log   string // where the event log is written
	Paced bool   // deliver ticks at TickRate, or as fast as they compute
}

// Run plays one world from beginning to end and writes its history to
// cfg.Log. It returns when the world has taken cfg.Ticks ticks, or
// when stop is closed, whichever happens first.
func Run(cfg Config, stop <-chan struct{}) error {
	lg, err := OpenLog(cfg.Log)
	if err != nil {
		return err
	}
	defer lg.Close()

	if err := lg.Append(Event{Kind: "start", What: fmt.Sprintf("seed %d", cfg.Seed)}); err != nil {
		return err
	}

	w := NewWorld(Generate(12, 8, cfg.Seed), cfg.Seed)
	w.Record(lg)
	for _, s := range []struct {
		kind EntityKind
		at   Coord
	}{
		{Shrub, Coord{X: 2, Y: 5}},
		{Walker, Coord{X: 10, Y: 6}},
		{Walker, Coord{X: 2, Y: 2}},
	} {
		if _, err := w.Spawn(s.kind, s.at); err != nil {
			return err
		}
	}

	var beat <-chan time.Time
	if cfg.Paced {
		t := time.NewTicker(TickDuration)
		defer t.Stop()
		beat = t.C
	}

	reason := "ticks complete"
	for w.Tick() < cfg.Ticks {
		if beat != nil {
			<-beat
		}
		select {
		case <-stop:
			reason = "stopped"
		default:
		}
		if reason == "stopped" {
			break
		}
		if err := w.Step(); err != nil {
			return err
		}
		if err := w.Faulted(); err != nil {
			return err
		}
	}
	return lg.Append(Event{Tick: w.Tick(), Kind: "stop", What: reason})
}
// cmd/worldd/main.go — flags, signals, and the goroutine
func main() {
	seed := flag.Uint64("seed", 5, "the number that names this world")
	ticks := flag.Uint64("ticks", 600, "how many ticks to take")
	logPath := flag.String("log", "world.jsonl", "where to write the event log")
	fast := flag.Bool("fast", false, "deliver ticks as fast as they compute")
	flag.Parse()

	cfg := sim.Config{Seed: *seed, Ticks: *ticks, Log: *logPath, Paced: !*fast}
	fmt.Printf("worldd %s seed %d ticks %d -> %s\n", version, cfg.Seed, cfg.Ticks, cfg.Log)

	stop := make(chan struct{})
	sig := make(chan os.Signal, 1)
	signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
	go func() {
		<-sig
		close(stop)
	}()

	done := make(chan error, 1)
	go func() { done <- sim.Run(cfg, stop) }()

	if err := <-done; err != nil {
		fmt.Fprintln(os.Stderr, "worldd:", err)
		os.Exit(1)
	}
	fmt.Println("worldd: the world stopped cleanly")
}
$ go run ./cmd/worldd -fast -ticks 600 -log a.jsonl
worldd 0.0.1 seed 5 ticks 600 -> a.jsonl
worldd: the world stopped cleanly

Two decisions in Config are deliberate. The seed and the tick count are inputs a caller supplies, so a run is fully described by a value you can print, store, and paste into a bug report. And Paced separates the delivery of ticks from their content, which is chapter 7's rule finally paying a dividend. Paced, the ticker hands out a tick every hundred milliseconds and 600 ticks take a minute; with -fast the same 600 ticks land as quickly as the processor can compute them, and the resulting world is identical in every respect, because nothing inside the simulation has any way to ask how long a tick took. A minute of world, verifiable in milliseconds.

Run ends by writing a stop line naming why it stopped. Two runs that both finished their tick count agree on that line; a run cut short by a signal records the tick it actually reached, which is a real difference in what happened and belongs in the history.

Now the test can hold a whole world. It runs one, runs another, and compares every byte of the two files.

▣ Build · stage 3: the replay test
// internal/sim/replay_test.go
package sim

import (
	"bytes"
	"os"
	"path/filepath"
	"testing"
)

// history plays one world into a file of its own and returns every
// byte the run wrote.
func history(t *testing.T, cfg Config) []byte {
	t.Helper()
	cfg.Log = filepath.Join(t.TempDir(), "world.jsonl")
	if err := Run(cfg, nil); err != nil {
		t.Fatalf("run seed %d: %v", cfg.Seed, err)
	}
	b, err := os.ReadFile(cfg.Log)
	if err != nil {
		t.Fatalf("read log: %v", err)
	}
	return b
}

// firstDiff reports the number of the first line on which two
// histories disagree, and both versions of it.
func firstDiff(a, b []byte) (int, string, string) {
	x := bytes.Split(a, []byte("\n"))
	y := bytes.Split(b, []byte("\n"))
	for i := 0; i < len(x) && i < len(y); i++ {
		if !bytes.Equal(x[i], y[i]) {
			return i + 1, string(x[i]), string(y[i])
		}
	}
	return min(len(x), len(y)) + 1, "", ""
}

// body drops the start line, which names the seed and so is expected
// to differ between two worlds.
func body(b []byte) []byte {
	_, rest, _ := bytes.Cut(b, []byte("\n"))
	return rest
}

func TestSameSeedSameHistory(t *testing.T) {
	cfg := Config{Seed: 5, Ticks: 600}

	first := history(t, cfg)
	second := history(t, cfg)

	if len(first) == 0 {
		t.Fatal("the run wrote no history at all")
	}
	if !bytes.Equal(first, second) {
		n, a, b := firstDiff(first, second)
		t.Fatalf("two runs of seed %d disagree at line %d\n  run 1: %s\n  run 2: %s",
			cfg.Seed, n, a, b)
	}
	t.Logf("seed %d: %d bytes of history, identical twice", cfg.Seed, len(first))
}

func TestDifferentSeedsDifferentHistories(t *testing.T) {
	five := history(t, Config{Seed: 5, Ticks: 600})
	nine := history(t, Config{Seed: 9, Ticks: 600})

	if bytes.Equal(body(five), body(nine)) {
		t.Fatal("seeds 5 and 9 wrote the same history; the seed is being ignored")
	}
	n, _, _ := firstDiff(body(five), body(nine))
	t.Logf("seeds 5 and 9 part company at event %d", n)
}
$ go test -v ./internal/sim/   (elapsed times measured on the author's machine; yours will differ)
=== RUN   TestGenerateRepeatsItself
--- PASS: TestGenerateRepeatsItself (0.00s)
=== RUN   TestSameSeedSameHistory
    replay_test.go:59: seed 5: 65804 bytes of history, identical twice
--- PASS: TestSameSeedSameHistory (0.00s)
=== RUN   TestDifferentSeedsDifferentHistories
    replay_test.go:70: seeds 5 and 9 part company at event 4
--- PASS: TestDifferentSeedsDifferentHistories (0.00s)
PASS
ok  	theworld/internal/sim	0.010s

Sixty-five thousand bytes of world, written twice, with no byte out of place. Three small decisions hold that result up. t.TempDir() hands back a fresh directory per call, which the log's design demands: the file is opened for append, so two runs pointed at one path would stack their histories into one file and the test would compare something other than what it claims. t.Helper() marks history as plumbing, so a failure inside it is reported at the calling line in the test. And the second test stops the first from being a fraud: a Run that ignored its seed, or wrote nothing at all, would sail through an equality check. Something has to insist that different seeds still produce different worlds, and that they diverge at the fourth event, the first move either walker makes.

The test proves the property inside one process. The claim is bigger than that, so make it again the hard way, with two separate executions of a compiled binary. Delete the logs first, since an append-only file happily survives its own program.

▣ Build · stage 4: two binaries, one history
go build -o worldd ./cmd/worldd
rm -f a.jsonl b.jsonl
./worldd -fast -seed 5 -ticks 600 -log a.jsonl
./worldd -fast -seed 5 -ticks 600 -log b.jsonl
diff -u a.jsonl b.jsonl && echo "identical"
$ ./worldd -fast -seed 5 -ticks 600 -log a.jsonl; ./worldd -fast -seed 5 -ticks 600 -log b.jsonl
worldd 0.0.1 seed 5 ticks 600 -> a.jsonl
worldd: the world stopped cleanly
worldd 0.0.1 seed 5 ticks 600 -> b.jsonl
worldd: the world stopped cleanly
$ diff -u a.jsonl b.jsonl && echo "identical"; wc -l a.jsonl b.jsonl
identical
   953 a.jsonl
   953 b.jsonl
  1906 total
$ sha256sum a.jsonl b.jsonl
4cb81462cf8875fc307dceacba66f8e51fc7b8777866edaf3c271ea06c5d5139  a.jsonl
4cb81462cf8875fc307dceacba66f8e51fc7b8777866edaf3c271ea06c5d5139  b.jsonl

diff printing nothing is the whole result: 953 lines apiece and no line that differs, from two processes that shared no memory and ran at different moments on a machine doing other things in between. The checksums say it in one number each. Two tools, two definitions of "the same", one verdict.

Nondeterminism bug

A passing test proves nothing until you have watched it fail for the right reason. So break the world the way it actually gets broken, with a change that is faster, simpler, and correct-looking.

⚠ Worked failure: the tidy optimization that erased two hundred moves

wander asks for Roster() every tick, and Roster allocates a fresh slice and fills it by walking IDs. Ten times a second, forever, for a list the world already holds in a map. Deleting the middleman is a one-word edit.

// internal/sim/tick.go — the "obvious" improvement
func (w *World) wander() error {
	for _, e := range w.ents { // was: for _, e := range w.Roster()
		if e.Kind != Walker {
			continue
		}
		d := steps[w.rng.IntN(len(steps))]
		err := w.Move(e.ID, e.At.Offset(d.X, d.Y))
		// ... unchanged
	}
	return nil
}

Run the server and nothing looks wrong. It starts, it ticks, it stops cleanly, it writes a log full of plausible walkers taking plausible steps. Run the test:

$ go test ./internal/sim/   (a broken world fails differently every time; these two runs are the author's)
--- FAIL: TestSameSeedSameHistory (0.00s)
    replay_test.go:56: two runs of seed 5 disagree at line 5
          run 1: {"t":1,"ev":"move","id":3,"from":{"x":2,"y":2},"to":{"x":2,"y":1}}
          run 2: {"t":1,"ev":"move","id":2,"from":{"x":10,"y":6},"to":{"x":10,"y":5}}
FAIL
FAIL	theworld/internal/sim	0.011s
FAIL
$ go test -count=1 ./internal/sim/
--- FAIL: TestSameSeedSameHistory (0.00s)
    replay_test.go:56: two runs of seed 5 disagree at line 13
          run 1: {"t":6,"ev":"move","id":2,"from":{"x":9,"y":4},"to":{"x":10,"y":4}}
          run 2: {"t":6,"ev":"move","id":2,"from":{"x":9,"y":4},"to":{"x":8,"y":4}}
FAIL
FAIL	theworld/internal/sim	0.011s
FAIL

Read the first failure. At tick 1 one run moved walker 3 and the other moved walker 2 first: the same two moves, in opposite order. Go randomizes the starting point of every range over a map, on purpose, precisely so that nobody can come to depend on an order the language does not promise. The second failure shows what that costs once the order touches the seeded stream. Both runs move walker 2 out of {9 4} at tick 6, and one sends it east while the other sends it west, because whichever walker is served first takes the next draw from w.rng, and after one swap the two runs are drawing from the same sequence in different places forever. Neither failure will be the failure you get: a broken world lands somewhere new every time, which is the entire complaint.

The size of the damage is easier to see from the shell, comparing two runs of the broken binary. What follows is one such pair, captured once; yours will land elsewhere, and that is the point:

$ diff a.jsonl b.jsonl | head -4; wc -l a.jsonl b.jsonl   (one roll of a broken binary; no two are alike)
8d7
< {"t":3,"ev":"move","id":3,"from":{"x":1,"y":2},"to":{"x":0,"y":2}}
9a9
> {"t":3,"ev":"move","id":3,"from":{"x":1,"y":2},"to":{"x":0,"y":2}}
   977 a.jsonl
   957 b.jsonl
  1934 total

It opens as one line displaced by a single position and ends as two different worlds: 977 events against 957 on this roll, and of those, 815 lines of the first history appear nowhere in the second. The correct binary writes 953 lines for seed 5, every time; neither of these is that number, and running the pair again gives two more numbers that are neither. Look closely and the ground itself survives intact — the spring floods the same fifteen squares in both runs, because two walkers still draw twice per tick and the pond's own draws land in the same places. It is the walkers that come apart, and once they are on different cells the two logs are describing different afternoons. Nothing about that is visible in either run on its own, and no amount of reading the code would tell you which of the two is the real seed 5. Putting Roster() back fixes it, and the cost of the fix is one small allocation per tick, which is a bargain for the only property that makes any of this history worth keeping.

The general lesson is bigger than maps. Any decision the world makes by consulting something the seed does not control (map order, wall-clock time, a goroutine that happened to finish first) leaks the machine's mood into the world's history. The replay test does not care which. It reports the first line where the world stopped agreeing with itself, and you go and find out why.

Determinism boundary

Determinism at this scale is not luck, and not something you sprinkle on later. It holds because every input to a decision inside the world passes through a boundary you drew, and only two things are allowed across: the seed and the tick count. Draws come from a generator built from the seed, in a fixed order. Entities are served in ID order, which the world assigns. Sim time comes from a counter the world increments. The laws consult none of the machine's ambient facts, because the code never asks for one. Same two inputs, same sequence of decisions, and the log records that sequence in the order it happened.

The determinism boundary: what may reach the world's laws A vertical boundary separates a left column of possible inputs from the world's laws on the right. Only the seed and the tick count cross it. Wall clock, map order, goroutine timing and CPU speed are stopped at the boundary and marked blocked. On the right, the world's laws box lists seeded draws, ID order and the tick counter, feeding one event log file. WHAT MAY REACH THE WORLD'S LAWS seed tick count wall clock map order goroutine timing cpu speed the boundary seeded draws entities in ID order the tick counter the world's laws world.jsonl same two inputs in, same bytes out

Figure 10.1: the replay test guards this boundary. Anything that gets across it on the left without being an input shows up on the right as a differing line.

That property is the contract every larger part of this book signs. A faithful snapshot can be checked by replaying the log from the beginning and comparing the rebuilt state, byte for byte, which only means anything if replaying is exact.

Server and client agreement uses the same property: replay the seed and the tick range on both sides, then compare what happened. A result you cannot reproduce is not a result, and "run it again with seed 5" is the difference between an experiment and an anecdote.

Determinism is not a feature you add once; it is a property one careless line can destroy at any point, and it destroys quietly: no crash, no error, just a world that answers the same question differently on Tuesday. The test runs before everything else because it is cheap, exact, and the witness that notices.

◆ Note: why the runs in this book are replayed, not described

Every output printed in these pages comes from actually running the code in a container, and the ones from deterministic systems get checked byte for byte against what the page claims. That is the same machine you just built, pointed at the book instead of at the world: a fixed seed, a run, and a comparison with no tolerance in it. Where a number cannot work that way, because a language model wrote it or because it measures your hardware, the page says so and tells you your run will differ.

Checkpoint

✓ Checkpoint: what you can now do
  • Write a _test.go file in the same package as the code it tests, and say why test files never end up inside the worldd binary.
  • Choose between t.Fatalf and t.Errorf, use t.Helper() so failures point at the calling line, and use t.TempDir() so an append-only log never carries over between runs.
  • Restructure a program so a test can reach it, moving a run behind sim.Run(cfg, stop) and leaving flags and signals in main.
  • Explain why -fast and the paced ticker produce identical histories, and use that to verify a minute of sim time in milliseconds.
  • Prove byte-identical replay two ways: go test comparing two in-process runs, and diff plus sha256sum over the logs of two separate binaries.
  • Recognize a nondeterminism bug from a diff that starts with two swapped lines and ends with two different worlds, and name the usual culprits.
⚡ Exercises: try first, then reveal
Exercise 1: leak the clock on purpose. Add a Wall string field to Event with the tag json:"wall,omitempty", and set it on the start event from time.Now().Format(time.RFC3339Nano). Predict what the test says before running it.

It fails on line 1, and the message hands you the culprit with no investigation required:

$ go test ./internal/sim/
--- FAIL: TestSameSeedSameHistory (0.00s)
    replay_test.go:56: two runs of seed 5 disagree at line 1
          run 1: {"t":0,"ev":"start","what":"seed 5","wall":"2026-08-24T17:29:29.887000744-05:00"}
          run 2: {"t":0,"ev":"start","what":"seed 5","wall":"2026-08-24T17:29:29.888931288-05:00"}
FAIL

Under two milliseconds apart, and the histories are already different files. The answer is not to forbid wall time but to keep it out of the event stream: an operator's question about when a run happened belongs in the operator's own logs, where nothing replays. Take the field back out before continuing.

Exercise 2: stop it by hand. Run ./worldd -seed 5 -ticks 600 -log c.jsonl without -fast, press Ctrl-C after a couple of seconds, then look at the tail of the log. Is this a determinism bug?

A run interrupted after two seconds ends like this, with the tick reached depending on exactly when your finger landed:

$ tail -2 c.jsonl; wc -l < c.jsonl
{"t":22,"ev":"move","id":3,"from":{"x":3,"y":1},"to":{"x":4,"y":1}}
{"t":22,"ev":"stop","what":"stopped"}
43

Not a bug. The signal is an input from outside the world, as real as the seed, and the log records a run that stopped at tick 22 instead of 600. A bug would be the first 22 ticks disagreeing with the first 22 ticks of a full run, and they do not: head -42 of the uninterrupted log matches this file exactly. So the test fixes Ticks instead of stopping by signal, because a test controls its inputs and a keystroke is not one of them.

Exercise 3: five worlds instead of one. Write a table-driven test that replays seeds 5, 9, 10, 11 and 12 for 300 ticks each, using t.Run so every seed reports separately. Then run it with -count=5.

t.Run(name, func(t *testing.T){...}) starts a subtest with its own t, so one failing seed names itself instead of hiding inside a failure about "the seeds".

$ go test -v -run TestManySeeds ./internal/sim/
=== RUN   TestManySeedsReplay
=== RUN   TestManySeedsReplay/seed5
=== RUN   TestManySeedsReplay/seed9
=== RUN   TestManySeedsReplay/seed10
=== RUN   TestManySeedsReplay/seed11
=== RUN   TestManySeedsReplay/seed12
--- PASS: TestManySeedsReplay (0.01s)
    --- PASS: TestManySeedsReplay/seed5 (0.00s)
    --- PASS: TestManySeedsReplay/seed9 (0.00s)
    --- PASS: TestManySeedsReplay/seed10 (0.00s)
    --- PASS: TestManySeedsReplay/seed11 (0.00s)
    --- PASS: TestManySeedsReplay/seed12 (0.00s)
PASS
ok  	theworld/internal/sim	0.012s

Add seeds 6, 7 and 8 to the table and the subtest naming pays for itself immediately:

$ go test -v -run TestManySeeds ./internal/sim/ 2>&1 | grep -E "FAIL|seeds_test"
    seeds_test.go:13: run seed 6: spawn: walker at {2 2} is water: terrain refuses the entity
    seeds_test.go:13: run seed 7: spawn: walker at {2 2} is water: terrain refuses the entity
    seeds_test.go:13: run seed 8: spawn: walker at {10 6} is water: terrain refuses the entity
--- FAIL: TestManySeedsReplay (0.01s)
    --- FAIL: TestManySeedsReplay/seed6 (0.00s)
    --- FAIL: TestManySeedsReplay/seed7 (0.00s)
    --- FAIL: TestManySeedsReplay/seed8 (0.00s)
FAIL

Not a replay failure at all, and not the same failure three times either. The line number is the giveaway: 13 is where the subtest calls history, and history is a t.Helper(), so a failure raised deep inside Run is reported against the line you would actually go and read. Seeds 6 and 7 put water on {2 2}, so it is the second walker Run spawns that the ground refuses; seed 8 floods {10 6} instead and stops the first one. Three of the eight seeds a reasonable person would try cannot even reach tick 1, because two starting positions are written into Run as literals and no terrain was ever asked to agree with them. A test that runs many worlds finds the ones your hard-coded starting positions were never designed for, which is a real defect in Run waiting for a real fix.