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

Errors That Don't Lie

Bad coordinates

The world has ground, a seed, and its first inhabitants, and it still trusts every request that reaches it. The boundary changes here: a function that can fail returns an error as an ordinary value, and the caller reads it before touching the result.

Grid.At believes every coordinate it is handed. Grid.Set writes wherever it is told. The entity map hands back whatever lives under an ID, including nothing.

Chapter 3's worked failure showed what that trust costs: one wrong index and the program died mid-run with index out of range. Back then the crash was almost a favor, a loud arrow pointing at broken arithmetic.

That crashed program was a demo you restarted with one keystroke. A server running The Hollow for months does not get to treat a bad coordinate as a full-process event; one bad request cannot take the valley with it.

A request into the simulation can already be wrong in several ways. A coordinate can name a cell that does not exist, off any of four edges. A placement can ask for ground that refuses the occupant: a walker cannot stand in the pond. A lookup can ask for an entity ID the map has never held, or held once and no longer does.

None of these are broken arithmetic. They are requests the world should decline, made by code that deserves an answer. Go's error values let the world say exactly why and keep ticking.

Walking off the map

Start with the damage as it stands. The pieces on the bench are the hand-set valley from chapter 3 and the entity layer exactly as the last chapter left it.

Predictable terrain makes error demos honest, so the pond returns for one more chapter. The entity layer has no method that moves anything.

Chapter 5 gave the world Spawn, Get, and the placement rule CanStand. Walking is something main does for itself, by taking the pointer Get hands back, asking the ground what is there, and writing a new coordinate into the entity if the kind can stand on it.

⚠ Worked failure: two walkers marched west, nobody checking
// cmd/worldd/main.go — walking, the only way chapter 5 left open

// marchWest steps one entity one cell west, n times, refusing only
// ground its kind cannot stand on.
func marchWest(w *sim.World, id sim.EntityID, n int) {
	for i := 0; i < n; i++ {
		e, _ := w.Get(id)
		to := e.At.Offset(-1, 0)
		if e.Kind.CanStand(w.Ground.At(to)) {
			e.At = to
		}
		fmt.Println("walker", id, "at", e.At)
	}
}
// cmd/worldd/main.go — after building the chapter 3 valley
	w := sim.NewWorld(g)

	scout, _ := w.Spawn(sim.Walker, sim.Coord{X: 2, Y: 5})
	marchWest(w, scout, 7)

	watch, _ := w.Spawn(sim.Walker, sim.Coord{X: 1, Y: 0})
	marchWest(w, watch, 2)
$ go run ./cmd/worldd
walker 1 at {1 5}
walker 1 at {0 5}
walker 1 at {-1 5}
walker 1 at {-2 5}
walker 1 at {-3 5}
walker 1 at {-4 5}
walker 1 at {-4 5}
walker 2 at {0 0}
panic: runtime error: index out of range [-1]

goroutine 1 [running]:
theworld/internal/sim.(*Grid).At(...)
	/home/you/theworld/internal/sim/terrain.go:62
main.marchWest(...)
	/home/you/theworld/cmd/worldd/main.go:17
main.main()
	/home/you/theworld/cmd/worldd/main.go:46 +0x33b
exit status 2

Two walkers, two different disasters, one cause. Walker 1 leaves the map at {-1 5} and nothing objects, because nothing checked.

Trace the arithmetic: At({-1, 5}) computes slot 5·12 + (−1) = 59, a perfectly legal slot that belongs to {11 4}, the east rim one row up.

The flattening formula, fed a coordinate that names no cell, quietly hands back some other cell, and the walker keeps marching through negative territory reading phantom ground.

It finally halts at {-4 5}: slot 5·12 − 5 = 55 is {7 4}, the pond's south-east corner, so the walker, four squares off the map, was stopped by water it never stood next to. Live state now contains an entity at a coordinate the grid cannot answer for.

Walker 2 shows the other face. On row 0, stepping to {-1 0} computes slot −1, and the runtime kills the process, taking walker 1, the terrain, and everything else with it.

The panic is the lucky symptom of the bug, and only row 0 gets the luck. Everywhere else the same mistake corrupts silently, and chapter 3 warned this exact species survives on any coordinate whose bad index still lands in range.

A bounds check fixes both faces at once, and the interesting question is what the check should do when it fires. Crashing on purpose makes every row behave like row 0. The world needs a way to say no and survive.

Grid errors

In Go, error is a type like any other: an interface satisfied by anything with an Error() string method. That makes an error an ordinary value you can return, store, compare, and print.

A function that can fail returns it as an extra result, last by convention. nil, the value meaning "no error here", is the success signal.

The simplest way to mint an error is errors.New. The more useful way, once one exists, is fmt.Errorf, which builds a message and can wrap another error inside it. Both grid methods get the treatment.

▣ Build · stage 1: At and Set with a bouncer at the door
// internal/sim/terrain.go — the imports grow, and one sentinel appears
import (
	"errors"
	"fmt"
	"strings"
)

// ErrOutOfBounds reports a coordinate that names no cell on this grid.
var ErrOutOfBounds = errors.New("coordinate is off the grid")

// At returns the terrain at a coordinate, or an error if the
// coordinate names no cell.
func (g *Grid) At(c Coord) (Terrain, error) {
	if !g.In(c) {
		return Rock, fmt.Errorf("terrain at %v: %w", c, ErrOutOfBounds)
	}
	return g.cells[g.index(c)], nil
}

// Set overwrites the terrain at a coordinate, or refuses if the
// coordinate names no cell.
func (g *Grid) Set(c Coord, t Terrain) error {
	if !g.In(c) {
		return fmt.Errorf("set terrain at %v: %w", c, ErrOutOfBounds)
	}
	g.cells[g.index(c)] = t
	return nil
}
// cmd/worldd/main.go — poke the new contract
	t, err := g.At(sim.Coord{X: 5, Y: 3})
	fmt.Println("at {5 3}:", t, err)
	t, err = g.At(sim.Coord{X: -1, Y: 5})
	fmt.Println("at {-1 5}:", t, err)
	err = g.Set(sim.Coord{X: 12, Y: 0}, sim.Soil)
	fmt.Println("set {12 0}:", err)
$ go run ./cmd/worldd
at {5 3}: water <nil>
at {-1 5}: rock terrain at {-1 5}: coordinate is off the grid
set {12 0}: set terrain at {12 0}: coordinate is off the grid

Read the signature first: At now returns two values, and Go makes the caller catch both. That is the idiom's teeth.

You cannot take the terrain and ignore the fact that there might not be any. The compiler rejects a call that binds one result of a two-result function.

The second poke shows a refusal: {-1 5}, the exact coordinate the scout corrupted itself with, now comes back with an error naming the operation, the coordinate, and the objection.

It also comes back with rock, and that first result is filler. When err is non-nil, the other return exists only because the signature demands something, and a caller that touches it before checking err has already lost. Error first, result second, every time.

Two details carry weight. ErrOutOfBounds is a package-level variable, a sentinel: one shared error value that means this one condition, exported so callers can recognize it later.

The %w verb in fmt.Errorf wraps the sentinel inside the longer message instead of pasting its text. The returned error carries the readable sentence on the outside and the identity of ErrOutOfBounds intact on the inside. Stage 3 collects that debt.

One consequence stays inside the package. Render called the old At in its loop, and its coordinates are generated by the loop itself, provably on the grid. It now reads g.cells[g.index(...)] directly.

Generate is the same case. Its soil loops run between the rim's bounds and its water walk refuses any step that would leave the interior, so every coordinate it writes is one it just proved, and it too assigns through g.cells[g.index(...)].

The boundary checks. Interior code that manufactures its own valid coordinates does not pay the toll twice.

Entity errors

The entity layer has more ways to disappoint a caller, so it gets two sentinels of its own: one for an ID the map does not hold, one for ground that refuses an occupant.

The placement rule itself moves into a small unexported method, allows, that both Spawn and Move consult. The definition of "may stand here" lives in exactly one place, the same discipline that kept the flattening formula in one function.

▣ Build · stage 2: Spawn, Get and Move that answer for themselves
// internal/sim/entity.go — sentinels and the one placement rule
var (
	// ErrNoEntity reports an ID the world has no entity for.
	ErrNoEntity = errors.New("no entity with that ID")
	// ErrBlocked reports terrain that refuses to hold an entity.
	ErrBlocked = errors.New("terrain refuses the entity")
)

// allows reports whether kind k may stand at a coordinate:
// nil for yes, an error naming the objection for no.
func (w *World) allows(k EntityKind, at Coord) error {
	t, err := w.Ground.At(at)
	if err != nil {
		return err
	}
	if k == Walker && t == Water {
		return fmt.Errorf("%v at %v is %v: %w", k, at, t, ErrBlocked)
	}
	return nil
}

// Spawn places a new entity and returns its ID, or refuses.
func (w *World) Spawn(k EntityKind, at Coord) (EntityID, error) {
	if err := w.allows(k, at); err != nil {
		return 0, fmt.Errorf("spawn: %w", err)
	}
	id := w.nextID
	w.nextID++
	w.ents[id] = &Entity{ID: id, At: at, Kind: k}
	return id, nil
}

// Get returns the entity with the given ID, or refuses.
func (w *World) Get(id EntityID) (*Entity, error) {
	e, ok := w.ents[id]
	if !ok {
		return nil, fmt.Errorf("entity %d: %w", id, ErrNoEntity)
	}
	return e, nil
}

// Move steps an entity to a new coordinate, or refuses and
// leaves the entity where it was.
func (w *World) Move(id EntityID, to Coord) error {
	e, ok := w.ents[id]
	if !ok {
		return fmt.Errorf("move entity %d: %w", id, ErrNoEntity)
	}
	if err := w.allows(e.Kind, to); err != nil {
		return fmt.Errorf("move entity %d: %w", id, err)
	}
	e.At = to
	return nil
}
// cmd/worldd/main.go — three requests, two of them doomed
	w := sim.NewWorld(g)

	scout, err := w.Spawn(sim.Walker, sim.Coord{X: 2, Y: 5})
	fmt.Println("spawned walker:", scout, err)

	drowned, err := w.Spawn(sim.Walker, sim.Coord{X: 5, Y: 3})
	fmt.Println("spawned walker:", drowned, err)

	_, err = w.Get(99)
	fmt.Println("lookup 99:", err)
$ go run ./cmd/worldd
spawned walker: 1 <nil>
spawned walker: 0 spawn: walker at {5 3} is water: terrain refuses the entity
lookup 99: entity 99: no entity with that ID

Follow one refusal end to end, because the layering is the lesson. Spawn asks allows; allows asks the grid, gets water back cleanly, applies its own rule, and returns ErrBlocked wrapped in a sentence naming the kind, the coordinate, and the ground.

Spawn wraps that once more with the operation that failed. Each layer adds the context only it knows, and the final message reads as a complete diagnosis a log file can stand on its own.

The comma-ok form e, ok := w.ents[id] distinguishes "absent" from "present". The absent case becomes a real answer with the missing ID in it, not a nil pointer waiting to detonate in whoever asked.

Every mutating path now refuses before touching state; a declined Move leaves the walker exactly where it was.

A caller still has to react to which error it got. A creature bumping ErrBlocked can try another direction; code seeing ErrOutOfBounds from its own computed coordinate has found a bug.

The messages differ, but string-matching messages is a trap. The wrapping that made them readable also made them unstable as identifiers.

This is what the sentinels and %w were for: errors.Is walks the wrapped chain and reports whether a given sentinel is anywhere inside it.

▣ Build · stage 3: the march replayed, and errors.Is naming the cause
// cmd/worldd/main.go — the failure's march, against the hardened API
	w := sim.NewWorld(g)
	scout, err := w.Spawn(sim.Walker, sim.Coord{X: 2, Y: 5})
	must(err)

	at := sim.Coord{X: 2, Y: 5}
	for {
		next := at.Offset(-1, 0)
		if err := w.Move(scout, next); err != nil {
			fmt.Println("stopped:", err)
			fmt.Println("off the grid?", errors.Is(err, sim.ErrOutOfBounds))
			fmt.Println("blocked by terrain?", errors.Is(err, sim.ErrBlocked))
			break
		}
		at = next
		fmt.Println("walker", scout, "at", at)
	}

	e, err := w.Get(scout)
	must(err)
	fmt.Println("the world is still running; the walker stands at", e.At)
$ go run ./cmd/worldd
walker 1 at {1 5}
walker 1 at {0 5}
stopped: move entity 1: terrain at {-1 5}: coordinate is off the grid
off the grid? true
blocked by terrain? false
the world is still running; the walker stands at {0 5}

Set this run beside the worked failure, same walker, same westward march. Before: six moves deep into negative coordinates, halted by phantom water, state corrupted, and a sibling walker's identical mistake killing the process.

After: two legal steps, one refusal at the exact edge, and a walker standing at {0 5} in a world still running.

errors.Is answers true for ErrOutOfBounds through two layers of wrapping, because %w preserved the chain: the move wrapper, the terrain message, the sentinel at the core.

A plain == against the sentinel would answer false here, since the outermost error is the wrapper, not the sentinel itself.

That is the pattern this book needs: sentinels for the conditions callers decide on, %w to add context without destroying identity, errors.Is to ask. Deeper machinery can wait until the world has a use for it.

The must(err) helper deserves its two lines, because it looks like a cheat and is a policy. main builds its valley from loop counters that cannot leave the grid, so if Set refuses one of them, the request was not bad; the program is.

must panics on any error it is handed. A returned error and a panic answer different questions: the error says "this request cannot be honored", the panic says "this code does not do what its author believed".

The first is Tuesday for a server. The second should be loud, immediate, and fatal, and now it is chosen instead of ambient.

Every request passes the same checks at the sim boundary A request, Move walker 1 west, arrives at a wall labeled the sim's boundary, which asks three questions: on the grid, terrain allows, entity exists. Beyond the wall sits the world's state. A no answer sends an error back along a return arrow to the caller; only a request that passes all checks reaches and changes state. CALLER Move(1, west) one request THE BOUNDARY ID exists? on the grid? terrain allows? every answer checked STATE e.At = to a "no" comes back as an error; state is never touched

Figure 6.1: the sim's methods are a wall around state: a request either passes every check or bounces back as an error, and nothing half-happens.

Boundary checks

The design principle underneath the syntax: in a long-running simulation, a panic and an error have different blast radii. A panic unwinds the whole process; whatever else the server was doing dies with the one call that went wrong.

A returned error is scoped to the request that earned it. The walker that tried to leave the map lost its move; the pond kept being a pond. Servers that live for months are built out of operations that fail small.

The idiom's plain surface is exactly what makes it carry. Because an error is a value, failure handling is ordinary code: you can wrap it, log it, count it, decide on it with errors.Is, or hand it up to a caller with more context, all with the same tools you use on any other value.

Because the failure path is spelled out at every call site, reading a Go function shows you the unhappy path at the same time as the happy one. The if err != nil blocks that look repetitive at chapter scale become, at server scale, the audit trail: every place a request can die is visible, searchable, and decided on purpose.

The checks live inside sim, at the boundary, not sprinkled through callers. Nothing outside the package can reach cells or ents directly, so every route to state passes the same few methods, and those methods now refuse everything illegal.

That means the bounds check is law, not advice. Weather, creatures, villagers, and player packets all have to pass methods that have stopped trusting anyone.

Checkpoint

✓ Checkpoint: what you can now do
  • Explain both faces of the unchecked bounds bug from the failure run: why row 0 panicked at slot −1, and why row 5 silently read {11 4} when asked for {-1 5}.
  • Write a function returning (T, error), and say what the non-error result is worth when the error is non-nil: nothing.
  • Define a sentinel with errors.New, wrap it with fmt.Errorf and %w, and predict what errors.Is and plain == each answer against the wrapped result.
  • Trace one refusal through three layers, grid to allows to Move, and say which piece of the final message each layer contributed.
  • Defend the split must enforces in one sentence each: errors are for requests that may be declined, panics are for code that is wrong.
⚡ Exercises: try first, then reveal
Exercise 1: remove, twice. Write Remove(id EntityID) error on *World: delete the entity from the map, or refuse an ID that is not there. Spawn a walker, remove it, then remove it again and print the second error. Which sentinel should errors.Is find in it?

The comma-ok lookup from Get, then the built-in delete(w.ents, id). The second call prints something like remove entity 1: no entity with that ID, and errors.Is(err, sim.ErrNoEntity) reports true. The double-remove test matters more than it looks: once entities can die, two systems will occasionally try to reap the same one, and "already gone" must be an answer, not a crash.

Exercise 2: nothing roots in bedrock. Extend allows with a second rule: a Shrub may stand only on soil. Spawn one shrub on the valley floor and one on the rim, and print both results. How many functions did the new rule touch?

One: allows. Add if k == Shrub && t != Soil { return fmt.Errorf(..., ErrBlocked) } and both Spawn and Move enforce it without changing, because they already ask allows for every placement. The rim attempt prints a refusal naming rock; the floor attempt returns a real ID. That is the payoff of routing every placement through one rule: the day terrain kinds multiply, legality still has one address.

Exercise 3: the comparison that lies. Capture the error from w.Move(scout, sim.Coord{X: -1, Y: 5}) and print both err == sim.ErrOutOfBounds and errors.Is(err, sim.ErrOutOfBounds). Predict the two booleans before running, then explain the difference in one sentence.

false, then true. The value Move returned is the outermost wrapper, a different error value from the sentinel, so == compares two distinct things and says no; errors.Is unwraps layer by layer, finds the sentinel at the core, and says yes. The one-sentence version: == asks "is this exactly that value?", while errors.Is asks "is that value anywhere in this chain?", and wrapped errors only ever answer the second honestly.