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

Rock, Water, Soil

The y·W + x slice

You have a coordinate and a cell, and nowhere to stand. The terrain rule is the first storage rule of the world: terrain is a typed constant, and the ground is one flat slice of them, addressed by y·W + x.

Everything asks the ground what sits underneath it. Water settles into low soil, plants root where rock does not stop them, animals drink where land meets water, and a village rises where all three meet.

That question has to be cheap, small, and hard to misread. A terrain kind is not a string and not a bare number; it is a named constant of its own type, so the compiler refuses nonsense before the world runs.

In memory, the grid of those kinds is a single flat slice. A little arithmetic turns any coordinate into a position in it.

worldd prints a real map by the end of the chapter: a rock rim, a soil valley, a pond. You place every one of those cells by hand, and that is deliberate.

Randomness is not needed to prove the container the terrain lives in. Hand-placing the cells means every number on the screen is one you can predict before the run.

The Terrain type

The obvious first move is a string per cell: "rock", "water", "soil". It works, and it is wrong twice over.

A string costs sixteen bytes of header plus the text itself, for information that has three possible values. Worse, "watre" compiles cleanly and fails at some distant runtime moment, in whatever code first compares against it.

Plain integers with a comment saying 0=rock 1=water 2=soil fix the size and keep the danger. Any int in the program can wander into a terrain's slot, including a count or a coordinate.

Go's answer is a defined type with named constants. Add this to internal/sim, next to the coordinate type from last chapter.

▣ Build · stage 1 — a type whose values have names
// internal/sim/terrain.go
package sim

// Terrain is what the ground is at one cell.
type Terrain uint8

const (
	Rock Terrain = iota // the zero value: a new world is solid rock
	Water
	Soil
)

// String makes a Terrain print as a word instead of a number.
func (t Terrain) String() string {
	switch t {
	case Rock:
		return "rock"
	case Water:
		return "water"
	case Soil:
		return "soil"
	}
	return "unknown"
}

// Glyph is the one-byte map symbol for a terrain kind.
func (t Terrain) Glyph() byte {
	switch t {
	case Water:
		return '~'
	case Soil:
		return '.'
	}
	return '#'
}
// cmd/worldd/main.go — a throwaway main to poke the new type
package main

import (
	"fmt"

	"theworld/internal/sim"
)

func main() {
	fmt.Println(sim.Rock, sim.Water, sim.Soil)
	fmt.Println(uint8(sim.Rock), uint8(sim.Water), uint8(sim.Soil))
	var t sim.Terrain
	fmt.Println("a fresh Terrain is", t)
}
$ go run ./cmd/worldd
rock water soil
0 1 2
a fresh Terrain is rock

type Terrain uint8 declares a new type whose underlying storage is one unsigned byte. It is not an alias: a uint8 holding a page count will not pass where a Terrain is expected unless you convert it on purpose.

The compiler now polices the difference between ground and arithmetic.

iota is Go's counter for constant blocks: it starts at 0 on the first line and climbs by one per line. Rock, Water, and Soil receive 0, 1, 2 without anyone typing the numbers, and adding a kind later is one new line, not a renumbering.

The String method is a quiet contract with the standard library: fmt checks whether a value knows how to describe itself and calls this method if so. The first printed line says rock water soil, while the converted second line shows the bytes underneath.

The third line is a design decision wearing a demo's clothes. Go initializes every variable; a Terrain nobody has assigned is 0, and 0 is Rock because Rock came first in the block.

That ordering is chosen. A freshly allocated world is solid bedrock, which is geologically sensible and obvious at a glance. A bug that forgets to assign terrain shows up as rock where you expected anything else, not as a plausible-looking valley.

◆ Note — why one byte matters at world scale

This chapter's grid is 12 by 8: 96 cells, 96 bytes, nothing. The type is being chosen for the world this book builds.

At 1,024 by 1,024 cells, one byte per cell is a single megabyte. Strings at that scale cost tens of megabytes and scatter the heap with tiny allocations. Picking the small representation now costs nothing and never has to be unpicked.

The flat slice

Now the storage. This is your first Go slice, and every later system reads this one; several rewrite it live.

A slice is a small header: a pointer to a run of elements, plus a length and a capacity. make([]Terrain, 96) builds one in a single step: allocate 96 contiguous bytes, zero them all, and return a header pointing at the run.

Zero terrain is Rock, so one call makes one block of memory with every cell already holding a legal value.

A slice is one-dimensional, and the world is not. Arithmetic is the bridge between them, and it is easier to see with numbers than with symbols.

Lay a 12-wide grid row after row into a slice: row 0 occupies slots 0 through 11, row 1 occupies 12 through 23, row 2 starts at 24. The cell at x=3, y=2 sits three slots into that third row: 24 + 3 = 27. Skip y full rows, then walk x slots into the current one.

∑ Math Interlude — the flattening formula

For a grid W cells wide, the cell at column x, row y lives at slot i = y·W + x. Check it against the numbers above: y=2, W=12, x=3 gives 2·12 + 3 = 27. The formula runs backwards too, using integer division and remainder: slot 27 in a 12-wide grid is row 27 ÷ 12 = 2 (integer division drops the fraction) and column 27 mod 12 = 3. Two multiplications a lookup, and no table anywhere.

Wthe grid's width: how many cells make one row
xcolumn, counted from 0 at the left edge
yrow, counted from 0 at the top edge
ithe slot in the flat slice: y·W + x
modthe remainder after integer division: 27 mod 12 = 3, written 27 % 12 in Go
A 4-wide, 3-high grid and the single flat slice that stores it Top: a grid of three rows of four cells, numbered 0 to 3, 4 to 7, and 8 to 11. The cell at column 2, row 1 is highlighted. Bottom: one row of twelve slots numbered 0 to 11, the same cells laid end to end, with slot 6 highlighted. An arrow connects the highlighted grid cell to slot 6, labeled with the formula 1 times 4 plus 2 equals 6. THE GRID YOU PICTURE (W = 4) 0 1 2 3 4 5 6 7 8 9 10 11 x = 2, y = 1 slot = 1·4 + 2 = 6 THE SLICE THAT EXISTS (rows laid end to end) 0 1 2 3 4 5 6 7 8 9 10 11 row 0 fills slots 0–3, row 1 fills 4–7, row 2 fills 8–11
▣ Build · stage 2 — the grid, with the formula in one private place
// internal/sim/terrain.go — continued

// Grid is the ground itself: W by H cells of terrain in one flat slice.
type Grid struct {
	W, H  int
	cells []Terrain
}

// NewGrid allocates a w-by-h grid in a single allocation.
// Every cell starts as the zero value, Rock.
func NewGrid(w, h int) *Grid {
	return &Grid{W: w, H: h, cells: make([]Terrain, w*h)}
}

// index turns a 2D coordinate into a position in the flat slice.
func (g *Grid) index(c Coord) int {
	return c.Y*g.W + c.X
}

// In reports whether a coordinate is on the grid at all.
func (g *Grid) In(c Coord) bool {
	return c.X >= 0 && c.X < g.W && c.Y >= 0 && c.Y < g.H
}

// At returns the terrain at a coordinate.
func (g *Grid) At(c Coord) Terrain {
	return g.cells[g.index(c)]
}

// Set overwrites the terrain at a coordinate.
func (g *Grid) Set(c Coord, t Terrain) {
	g.cells[g.index(c)] = t
}
// cmd/worldd/main.go — replace the throwaway main
func main() {
	g := sim.NewGrid(12, 8)
	c := sim.Coord{X: 3, Y: 2}
	fmt.Println("slot for (3,2):", c.Y*g.W+c.X)
	fmt.Println("before:", g.At(c))
	g.Set(c, sim.Water)
	fmt.Println("after: ", g.At(c))
	fmt.Println("neighbor untouched:", g.At(sim.Coord{X: 4, Y: 2}))
}
$ go run ./cmd/worldd
slot for (3,2): 27
before: rock
after:  water
neighbor untouched: rock

cells and index start with small letters, so nothing outside internal/sim can touch them. The rest of the program goes through At and Set or it does not get in.

That is last chapter's method discipline paying its first real rent: the flattening formula exists in exactly one function, three lines long. If the layout ever changes, one function changes with it, and no caller anywhere is holding a copy of the arithmetic.

The run confirms the formula against the interlude, 27 on the nose, and confirms that writing one cell disturbed nothing beside it.

Rendering the map

Render walks the grid row by row and emits one glyph per cell: # for rock, ~ for water, . for soil.

Then main lays soil across the interior, leaves the untouched rim as rock, and places fourteen water cells one by one to make a pond.

▣ Build · stage 3 — render it, and draw the first map
// internal/sim/terrain.go — add the import and the renderer
import "strings"

// Render draws the whole grid as one glyph per cell, one row per line.
func (g *Grid) Render() string {
	var b strings.Builder
	for y := 0; y < g.H; y++ {
		for x := 0; x < g.W; x++ {
			b.WriteByte(g.At(Coord{X: x, Y: y}).Glyph())
		}
		b.WriteByte('\n')
	}
	return b.String()
}
// cmd/worldd/main.go — the whole file
package main

import (
	"fmt"

	"theworld/internal/sim"
)

const version = "0.0.1"

func main() {
	fmt.Println("worldd", version, "starting")

	g := sim.NewGrid(12, 8)

	// Soil everywhere except a one-cell rock rim.
	for y := 1; y < g.H-1; y++ {
		for x := 1; x < g.W-1; x++ {
			g.Set(sim.Coord{X: x, Y: y}, sim.Soil)
		}
	}

	// A pond, placed by hand. Real generation needs randomness
	// the sim does not have yet.
	pond := []sim.Coord{
		{X: 4, Y: 2}, {X: 5, Y: 2}, {X: 6, Y: 2}, {X: 7, Y: 2},
		{X: 3, Y: 3}, {X: 4, Y: 3}, {X: 5, Y: 3}, {X: 6, Y: 3},
		{X: 7, Y: 3}, {X: 8, Y: 3},
		{X: 4, Y: 4}, {X: 5, Y: 4}, {X: 6, Y: 4}, {X: 7, Y: 4},
	}
	for _, c := range pond {
		g.Set(c, sim.Water)
	}

	fmt.Print(g.Render())
	fmt.Println("ground:", g.W*g.H, "cells,", len(pond), "of them water")
}
$ go run ./cmd/worldd
worldd 0.0.1 starting
############
#..........#
#...~~~~...#
#..~~~~~~..#
#...~~~~...#
#..........#
#..........#
############
ground: 96 cells, 14 of them water

Read it against the code. The top and bottom rows and both edge columns were never assigned, so they render as rock: the zero value drew the valley walls for free.

The pond's middle row runs from x=3 to x=8, one cell wider than the rows above and below it, and the map shows exactly that bulge.

Nothing here is generated, so nothing here can surprise you. That property becomes the baseline every run is judged against.

strings.Builder deserves its one sentence: appending to a string with + copies the whole string every time, while a builder accumulates bytes in a growing buffer and produces the final string once, at the end.

For 96 cells it is a habit. For the grids this world grows into, it is the difference between a render and a stall.

There is also a slice hiding in plain sight in main: pond is a []sim.Coord built from a literal. for _, c := range pond visits each element in order, with the underscore discarding the slot number that range also offers.

Slices of structs, iterated and consumed, become one of this book's steady patterns.

Why the grid is flat

The tempting representation was [][]Terrain, a slice of row slices, which lets you write grid[y][x] and feel done. The flat slice earns its keep in memory, not in syntax.

A slice of slices for a 12 by 8 grid is nine separate allocations, one for the outer spine and one per row. The rows land wherever the allocator has room; every grid[y][x] is two hops, first to the spine, then through a pointer to wherever that row lives.

The flat slice is one allocation. A lookup is one multiply, one add, one read.

The deeper reason is how the machine reads memory. A CPU never fetches one byte; it pulls 64-byte cache lines, so touching any cell drags its 63 nearest slice-neighbors into fast memory with it.

In the flat layout those neighbors are real terrain, most of the current row and a piece of the next. A row-by-row pass like Render finds nearly everything it wants already loaded.

Scattered rows squander that. The hardware keeps prefetching, but what arrives is whatever happens to sit next to each stray row. When a tick means sweeping the whole grid, the flat layout means the sweep runs at the speed memory streams instead of the speed pointers chase.

The formula also refuses to let the grid go ragged. A slice of slices can hold rows of different lengths, and nothing but discipline keeps a bug from appending to one row and not another.

In a flat slice, the dimensions live in W and H, and every cell exists precisely once. What the formula does demand is respect for the order of its two terms, and that is where this chapter's mistake lives.

⚠ Worked failure — two multiplications, one of them wrong

The formula is easy to invert without noticing. Suppose index had come out of your fingers as columns-first:

func (g *Grid) index(c Coord) int {
	return c.X*g.W + c.Y // BUG: skips c.X rows' worth of cells per column
}
$ go run ./cmd/worldd
worldd 0.0.1 starting
panic: runtime error: index out of range [97] with length 96

goroutine 1 [running]:
theworld/internal/sim.(*Grid).Set(...)
	/home/you/theworld/internal/sim/terrain.go:67
main.main()
	/home/you/theworld/cmd/worldd/main.go:19 +0x40a
exit status 2

Reason from the numbers, because they name the culprit. The slice holds 96 cells, so legal slots run 0 through 95, and something asked for 97.

x·12 + y = 97 means x=8, y=1: a legal cell, eight columns in, one row down, that the bad arithmetic mapped past the end of the slice.

Go's bounds check caught the first out-of-range write and stopped the program with the address of the crime. The stack trace (your path will be your checkout's) points into Set, called from main's soil loop. The fix is one swapped pair of letters.

The panic is the friendly version of this bug. On a square grid, x·W+y never leaves the slice, so nothing panics; the world comes out transposed, every map mirrored across its diagonal, and you get to notice that visually or not at all.

This is a reason to test on grids where width and height differ. A rectangle turns a silent scrambling into a loud, addressed, first-offense panic.

Checkpoint

✓ Checkpoint — what you can now do
  • Flatten any coordinate by hand, y·W + x, and run it backwards with integer division and remainder to recover the cell a slot belongs to.
  • Explain what type Terrain uint8 buys over a bare number or a string, and what iota is doing in the constant block.
  • Say what make([]Terrain, w*h) allocates, what every element holds the instant it exists, and why Rock was placed at zero on purpose.
  • Argue the flat slice against [][]Terrain on allocations, cache lines, and ragged rows, not on taste.
  • When a transposed map or an index-out-of-range panic comes from grid code, check the order of terms in the flattening formula and explain why a square grid hides the same bug.
⚡ Exercises — try first, then reveal
Exercise 1 — census. Write a method Count(t Terrain) int on *Grid that reports how many cells hold a given kind, and have main print the count for all three. Do the three numbers sum to 96?

A single loop over the flat slice, no coordinates needed: this is the first job where the flat layout is strictly easier than nested slices. The hand-drawn map counts 36 rock, 14 water, 46 soil, and 36 + 14 + 46 = 96. If your rock count is 40, your rim loops overlap at the corners; if the sum is not 96, some cell is being counted twice or missed, which the flat loop makes impossible, so check that you looped over cells and not over coordinates.

Exercise 2 — a fourth kind of ground. Add Sand to the terrain type, give it a glyph, and ring the pond with a one-cell beach. Where in the constant block does the new name go, and why does the position matter?

Append it after Soil, taking value 3, and extend both switches; a new case in String and one in Glyph. Appending matters because inserting Sand mid-block would renumber every constant below it, and the moment terrain values are written to disk (this volume's event log is coming), a renumbering silently relabels the saved world. The beach is fourteen to twenty Set calls or a small loop over the pond's neighbors; rerun and the map shows a pale ring where soil met water.

Exercise 3 — falling off the world. Ask the grid for Coord{X: -1, Y: 0} and read the panic. Then use the In method to build AtOK(c Coord) (Terrain, bool) that refuses instead of crashing, and prove both behaviors with two runs.

The direct read panics with index out of range [-1]: the formula computes 0·12 + (-1) and the bounds check rejects it. AtOK is three lines: if !g.In(c), return Rock, false; otherwise g.At(c), true. The two-value return is a Go idiom you will meet everywhere from map lookups to type checks. Returning rock for the void is a real design choice, not a shrug: to every wanderer in later volumes, the edge of the world behaves like an unbreakable cliff.