Something in the World
The grid says ground
The map has a rock rim, a soil floor, and a pond, but it cannot answer where a shrub stands. The split is permanent: the grid says what the ground is; entities say what stands on it, and no fact lives in both places.
A terrain grid can only say what the ground is. Everything built so far is of the world: rock, water, soil, properties of squares. Nothing yet is in it.
A world becomes a world when the first thing stands on the ground instead of being the ground.
Extending the terrain type looks tempting: add ShrubOnSoil and
WalkerOnSoil constants next to Rock and Soil,
and let the grid carry both facts.
That road collapses quickly. Every thing-on-ground combination needs a constant, and when something moves, the grid must remember what ground to restore behind it.
The grid also cannot say which shrub is which. Two shrubs are two identical bytes in a slice. Eat one, and no record anywhere can say whether it was the one by the pond or the one by the rim.
The ground is dense, one value per cell, because every cell has ground. Entities are sparse: a handful now, thousands later, most cells holding none. Dense facts live in the flat slice you already built. Sparse facts need a different container.
Things in a world also change. A creature moves, grows, gets hurt, heals, until almost everything about it differs from the day it appeared.
For the world to say "this is still the same one," something about it must stay permanent. The fix is old and everywhere: issue each entity a number when it enters the world, never change it, and never give it to anything else, even after the entity dies. The number is the identity. Everything else is current condition.
The Entity struct
The entity itself is small, and it should be. What is it, where is it, and which one is it: three fields.
Kinds get the treatment terrain kinds got, a defined type with iota
constants. The volume starts with two: a shrub that roots in soil, and a walker that
goes where it likes. Neither behaves yet. They exist, which today is the whole point.
// internal/sim/entity.go
package sim
// EntityID names one entity for its whole life. IDs are handed
// out once, in order, and never reused, even after a removal.
type EntityID uint64
// EntityKind is what sort of thing an entity is.
type EntityKind uint8
const (
Shrub EntityKind = iota
Walker
)
// String makes an EntityKind print as a word.
func (k EntityKind) String() string {
switch k {
case Shrub:
return "shrub"
case Walker:
return "walker"
}
return "unknown"
}
// Glyph is the one-byte map symbol for an entity kind.
func (k EntityKind) Glyph() byte {
if k == Walker {
return '@'
}
return 'o'
}
// Entity is one thing standing in the world: what it is, where it
// is, and the ID that stays the same while both of those change.
type Entity struct {
ID EntityID
Kind EntityKind
At Coord
}
// cmd/worldd/main.go — a throwaway main: store entities the way
// you already know how, and find one
func main() {
ents := []sim.Entity{
{ID: 1, Kind: sim.Shrub, At: sim.Coord{X: 2, Y: 5}},
{ID: 2, Kind: sim.Shrub, At: sim.Coord{X: 9, Y: 2}},
{ID: 3, Kind: sim.Walker, At: sim.Coord{X: 10, Y: 6}},
}
want := sim.EntityID(3)
for _, e := range ents {
if e.ID == want {
fmt.Println("found:", e.Kind, "at", e.At)
}
}
fmt.Println("looked at", len(ents), "entities to find one")
}
$ go run ./cmd/worldd
found: walker at {10 6}
looked at 3 entities to find one
EntityID is a defined type over uint64 for the same reason
Terrain sat over uint8: an ID is not a count, and the
compiler should refuse to let one impersonate the other.
Sixty-four bits is deliberate too. IDs are never reused, and at even a million spawns
a second a uint64 outlasts any machine you will ever own.
The storage is the honest problem in this stage. A slice answers "find entity 3" by walking every element and comparing. Three entities, three looks, fine.
The valley this book is heading toward holds thousands, and the systems that run it ask "where is entity so-and-so" constantly: every attack names a target, every memory names who it happened with, every log line names its actor.
Answering each by scanning the whole population turns the sim into a search engine for its own contents.
Be precise about what the slice is bad at. It holds entities beautifully, packed and iterable; it cannot jump straight to one when all you hold is its ID.
The grid solved this for cells with arithmetic, a coordinate computing its own slot. IDs climb forever and most are eventually dead, so "slot = ID" would mean a slice as long as every entity that ever lived.
The container you want acts as if that arithmetic existed: hand it a key, get the value, no scan. Go ships one.
The World map
A map is Go's built-in key-to-value store.
map[EntityID]*Entity reads as "a map from entity IDs to entity pointers".
Hand it an ID between square brackets and it hands back the pointer stored under that
ID, in effectively constant time, whether it holds three entries or three hundred
thousand.
You create one with make, like a slice; store with m[id] = e;
read with m[id]; remove with the built-in delete(m, id);
count with len(m).
Reading a key that was never stored does not fail. It returns the value type's zero
value, nil for a pointer. Go offers a second form,
e, ok := m[id], where ok answers whether this key was
actually present.
var m map[EntityID]*Entity declares a map holding its zero value,
nil: reads on a nil map politely return zero values, but a write
panics with assignment to entry in nil map. Constructors like the
NewWorld below exist for this: the one place a World
is born is the one place its map is guaranteed maked.
The map needs an owner. Placement is a negotiation between an entity and the ground under it, so the type holding the entities must also see the grid.
Call it World, and give it the spawning rules, the lookup, and the ID
counter that makes identity real.
// internal/sim/entity.go — add the placement rule to the kind
// CanStand reports whether this kind of entity may occupy ground of
// kind t. Shrubs root only in soil; a walker stands anywhere dry.
func (k EntityKind) CanStand(t Terrain) bool {
switch k {
case Shrub:
return t == Soil
case Walker:
return t != Water
}
return false
}
// internal/sim/world.go
package sim
// World owns the ground and everything standing on it.
type World struct {
Ground *Grid
ents map[EntityID]*Entity
nextID EntityID
}
// NewWorld wraps a terrain grid in a world with nothing standing on it.
func NewWorld(g *Grid) *World {
return &World{
Ground: g,
ents: make(map[EntityID]*Entity),
nextID: 1,
}
}
// Spawn creates an entity of kind k at c and returns its new ID.
// It refuses, returning 0 and false, if c is off the grid or the
// ground there cannot hold this kind of entity.
func (w *World) Spawn(k EntityKind, c Coord) (EntityID, bool) {
if !w.Ground.In(c) || !k.CanStand(w.Ground.At(c)) {
return 0, false
}
id := w.nextID
w.nextID++
w.ents[id] = &Entity{ID: id, Kind: k, At: c}
return id, true
}
// Get returns the entity with this ID, and whether it exists at all.
func (w *World) Get(id EntityID) (*Entity, bool) {
e, ok := w.ents[id]
return e, ok
}
// Remove takes an entity out of the world. Its ID retires with it.
func (w *World) Remove(id EntityID) {
delete(w.ents, id)
}
// Population is how many entities the world holds right now.
func (w *World) Population() int {
return len(w.ents)
}
// cmd/worldd/main.go — rebuild chapter 3's hand-drawn valley
// (the soil loop and the pond literal, unchanged), then populate it
w := sim.NewWorld(g)
id1, ok := w.Spawn(sim.Shrub, sim.Coord{X: 2, Y: 5})
fmt.Println("shrub:", id1, ok)
id2, ok := w.Spawn(sim.Shrub, sim.Coord{X: 9, Y: 2})
fmt.Println("shrub:", id2, ok)
id3, ok := w.Spawn(sim.Walker, sim.Coord{X: 10, Y: 6})
fmt.Println("walker:", id3, ok)
id4, ok := w.Spawn(sim.Walker, sim.Coord{X: 5, Y: 3})
fmt.Println("walker on the pond:", id4, ok)
fmt.Println("population:", w.Population())
$ go run ./cmd/worldd
shrub: 1 true
shrub: 2 true
walker: 3 true
walker on the pond: 0 false
population: 3
The fourth spawn is the placement rule earning its keep: {5, 3} is open water, a walker cannot stand there, and the world said no.
Nothing panicked and nothing was half-created. Spawn checks before it
allocates, so a refused spawn leaves no trace, not even a burned ID.
The rule lives on EntityKind, not buried inside Spawn,
because "can this kind occupy that ground" is a question other systems ask about
squares nobody is spawning into.
The lowercase fields of World matter too: the map and the counter are
private, so no caller can hand out an ID or plant something in the pond by reaching
around the methods.
// cmd/worldd/main.go — continue after the population line
if e, ok := w.Get(id3); ok {
fmt.Println("id", e.ID, "is a", e.Kind, "at", e.At)
e.At = e.At.Offset(0, -1)
}
if e, ok := w.Get(id3); ok {
fmt.Println("id", e.ID, "is a", e.Kind, "at", e.At)
}
w.Remove(id1)
_, alive := w.Get(id1)
fmt.Println("id 1 still in the world:", alive)
id5, _ := w.Spawn(sim.Shrub, sim.Coord{X: 2, Y: 5})
fmt.Println("replacement shrub's id:", id5)
$ go run ./cmd/worldd
shrub: 1 true
shrub: 2 true
walker: 3 true
walker on the pond: 0 false
population: 3
id 3 is a walker at {10 6}
id 3 is a walker at {10 5}
id 1 still in the world: false
replacement shrub's id: 4
The walker moved one row north and its ID did not flicker: position changed, identity held.
The mutation landed because the map stores *Entity, pointers. What
Get returns is the entity in the world, not a souvenir copy, so
e.At = ... writes through: the chapter 2 lesson about addresses,
applied to a container.
The shrub that replaced the removed one got ID 4, not a recycled 1. Recycling would save nothing and cost everything: the moment the world keeps records, a reused ID makes old records ambiguous about who they meant. Retired numbers stay retired.
Rendering entities
The map view should show the newcomers: render the ground as before, then stamp each entity's glyph over its cell.
// internal/sim/world.go — add
// Render draws the ground with every entity drawn over its cell.
func (w *World) Render() string {
rows := []byte(w.Ground.Render())
for _, e := range w.ents {
rows[e.At.Y*(w.Ground.W+1)+e.At.X] = e.Kind.Glyph()
}
return string(rows)
}
$ go run ./cmd/worldd (map portion of the output) ############ #..........# #...~~~~.o.# #..~~~~~~..# #...~~~~...# #.o.......@# #..........# ############
The W+1 is not a typo: the rendered ground is text, each row
W glyphs plus a newline, so a row of the byte slice is one wider than a
row of the grid.
The loop is your first iteration over a map: range visits every
key-value pair. Here the visiting order cannot matter, since each entity stamps only
its own cell.
Where order can matter, Go has an opinion, and it is about to become this chapter's mistake.
A world should list its population on demand. The obvious method writes itself: walk the map, collect the entities, print them.
// internal/sim/world.go — the obvious attempt
// Roster returns every entity in the world.
func (w *World) Roster() []*Entity {
out := make([]*Entity, 0, len(w.ents))
for _, e := range w.ents {
out = append(out, e)
}
return out
}
$ go run ./cmd/worldd (roster portion) roster: 4 shrub at {2 5} 2 shrub at {9 2} 3 walker at {10 5}
$ go run ./cmd/worldd (again, nothing changed) roster: 2 shrub at {9 2} 3 walker at {10 5} 4 shrub at {2 5}
Same code, same seed, same three entities, different order. Your own runs land in their own orders, possibly agreeing for several runs before they betray you.
Reason from the symptom: the entities are identical across runs, so what varies must
be the order range visits the map. That is exactly right.
Go deliberately randomizes map iteration order, starting each walk at a random bucket, so no program can accidentally depend on an order the language never promised.
For most programs that is a lint. For this one it is a fire alarm wired to the book's central promise: chapter 4 swore the same seed produces the same world, byte for byte, and here are two byte-different outputs from one seed.
Any map iteration whose results reach the world's output, its log, or its state must be forced into an order you choose. The fix costs four lines: collect the keys, sort them, walk them.
// internal/sim/world.go — the fix; add "slices" to the imports
// Roster returns every entity in the world, ordered by ID.
func (w *World) Roster() []*Entity {
ids := make([]EntityID, 0, len(w.ents))
for id := range w.ents {
ids = append(ids, id)
}
slices.Sort(ids)
out := make([]*Entity, 0, len(ids))
for _, id := range ids {
out = append(out, w.ents[id])
}
return out
}
$ go run ./cmd/worldd (roster portion — now identical on every run) roster: 2 shrub at {9 2} 3 walker at {10 5} 4 shrub at {2 5}
range with one variable over a map yields keys only.
slices.Sort, from the standard library's slices package,
sorts them in place, ascending.
Twelve runs in a row now produce twelve identical rosters, and ID order means something: entities list in the order they entered the world, oldest first, because IDs were issued in that order and never shuffled.
Identity, handed out for one purpose, quietly pays for another.
Why maps fit entities
A map feels like magic from the outside: any key, any of a million entries, one step. Under the hood it is the grid's own trick in disguise.
The flat slice turned a coordinate into a slot with y·W + x. A map turns
a key into a slot with a hash function, a computation that scrambles the key's
bytes into a number.
That number, reduced to the size of the map's internal array, picks the bucket where the value lives. Lookup is: hash the key, go to the bucket, check the few entries there. No scan, at any size.
The costs are the ones the grid never paid. Buckets need slack to keep collisions rare, so a map spends more memory per entry than a slice. Hashing costs more than one multiply. Entries land wherever their hash sends them, so neighbors in the map are strangers in memory.
That is why iterating a map will never match sweeping a slice. Read it as a fee schedule: dense, ordered, swept-every-tick data belongs in slices; sparse, keyed, jumped-to data belongs in maps. The ground and the population landed on opposite sides of that line, so the world now holds one of each.
The randomized iteration order follows from the same mechanics. Where an entry lands depends on its hash and the map's current internal size, so even without deliberate randomization, order would shift as the map grew.
Go adds the random start to make the lesson unavoidable in testing instead of
catastrophic in production. The standing rule for this project: a map is a bag, not a
queue, and any time the world speaks, logs, or acts on its population, the order comes
from a sort, never from range.
The ID scheme outlives every container decision. The entities on the map today are
frozen scenery: the walker moved because main moved it, and nothing in the
world moves anything on its own, because nothing yet says when.
The population map is the thing a tick loop can sweep. Entity 3 can move later, and the only thread connecting "the walker that drank at the pond" to "the walker that died on the rim" days later is the number that never changed.
Identity is what makes a history possible. The world remembers who, not a square that happened to hold something once.
Checkpoint
- Say which facts belong in the terrain grid and which belong in the entity map, and defend the dense-versus-sparse line that separates them.
- Create a map with
make, store, read,delete, andlenit, and usee, ok := m[id]to tell a missing key from a stored zero value. - Explain why
Spawnreturned0 falsefor the pond walker, and where the rule that refused it lives. - Trace why
e.At = e.At.Offset(0, -1)changed the entity in the world, naming the pointer inmap[EntityID]*Entitythat made it possible. - State the ID policy: issued in order, never changed, never reused, with the record-keeping reason recycling ID 1 would poison.
- When a program's output order changes between identical runs, suspect an unsorted map iteration first, and write the collect-sort-walk fix from memory.
Exercise 1: who is standing here? Write
AtCell(c Coord) []*Entity on *World: every entity
whose position is c, in a deterministic order. Prove it by asking
about the replacement shrub's square and an empty one. What did this lookup
cost, compared to Get?
The map is keyed by ID, not by place, so this question scans: loop over
Roster() (already sorted, so the order is settled) and keep
entities where e.At == c; struct equality works because
Coord is two comparable ints. The shrub's square prints one
entity, the empty square none, and the cost is the full population per
call, the exact price the slice charged for Get. A store is
fast only for the key it is organized under; when position lookups become
hot, the sim needs a second structure organized by place. This scan is the
cost that justifies it.
Exercise 2: census by kind. Build a
map[EntityKind]int that counts the population by kind, then print
shrubs and walkers. What does counts[k]++ do the first time a
kind appears, before any entry for it exists?
Loop over the roster and increment counts[e.Kind]++. The
first increment for a kind reads a missing key, and a missing key reads as
the value type's zero: 0. So ++ turns "absent" into 1 with no
existence check; that behavior makes maps natural counters in Go. The print
shows 2 shrubs, 1 walker. If you printed by ranging over counts
itself, you know which trap you just re-armed.
Exercise 3: refused for two different reasons.
Spawn a shrub at {0, 0} and a walker at {-1, 3}. Both return
0 false. Which of Spawn's two checks refused each,
and why does the order of those checks matter?
The shrub failed CanStand: {0, 0} is on the grid, but it is
rim rock and shrubs root only in soil. The walker failed In:
{-1, 3} is off the grid entirely. The order matters because
CanStand needs At, and At on an
off-grid coordinate panics; In standing guard first means the
ground is only consulted about squares that exist. Swap the two conditions
and the walker spawn crashes instead of returning false. Try
it; reading that panic on purpose is cheap insurance.