Watching the Spring Seep
The copied frame
Every renderer this volume built drew a world that had already finished moving. The tilemap read a grid nobody was writing to. The readout printed three numbers taken from a world that had stopped a hundred and twenty ticks ago. Even the blend between two ticks was fed by a client that stepped the simulation itself, in its own loop, between frames.
Volume 1 does not work that way. Its world runs inside sim.Run, on its own
goroutine, told to stop through a channel, and it takes a tick every hundred milliseconds
whether or not anybody is looking at it. The simulation owns the world and never
lends it. When a tick is finished it copies out the few numbers a picture needs, hands the
copy over, and never writes to that copy again; the renderer draws copies and asks the
world nothing.
Chapter 15's shell owns the frame loop: it calls Step and Draw
when the display is ready, and it will not be told to wait. There are two loops now, each
convinced it is in charge, and one screen between them.
The obvious way to join them is one line long. sim.Config has a
Watch field that the simulation calls after every tick; hand the client the
world through it and the renderer can read whatever it likes, whenever it likes. That line
compiles, runs, opens a window and draws The Hollow.
It is also a data race, of the exact kind chapter 9 spent a page warning about, and the broken version below lets the tool say so. The rule becomes the whole client: one tick's copies, a snapshot builder on the simulation goroutine, a view on the window goroutine, a five-cell ground update, and a headless mode where the channel orders the ticks a hash is written against.
DrawMapVia
None of that can be drawn yet, though, because a part underneath it was never written,
and it is the last gap this volume leaves. Chapter 19 gave the tile map its blitter:
DrawMap walks every cell and lands the map's own (0, 0) on a screen pixel
you hand it, which is all a picture of a whole valley on a screen the size of the valley
ever needs. Chapter 20 gave the camera the two answers a moving window needs.
Cells is the range of cells a view touches; ToScreen is the
pixel a world coordinate lands on. It proved culling with both of them on a stand-in map
of flat coloured squares, which was the right map to prove it on and the wrong one to
ship.
Nothing ever joined the two. The client draws a real tile map, out of the sheet chapter
18 drew, through a camera that moves, and no function in internal/render
does that job. The function is short: ask the camera which cells the view touches, then
run chapter 19's blit inside those bounds with the camera deciding each cell's screen
pixel instead of an origin plus an offset. It returns how many cells reached the blitter,
and that count is the only outward sign culling is happening at all.
// internal/render/tilemap.go
// DrawMapVia blits the cells of a tile map the camera can see, each at
// the screen pixel the camera puts it on, and returns how many it drew.
// Cells outside the viewport are never offered to the blitter: DrawMap
// leans on the clip rectangle to throw them away, which is honest work
// on a map of ninety-six cells and waste on a map of thousands.
func DrawMapVia(b *Buffer, m *TileMap, s *Sheet, cam Camera) int {
vis := cam.Cells(TileCell, m.Cols, m.Rows)
n := 0
for cy := vis.Y0; cy < vis.Y1; cy++ {
for cx := vis.X0; cx < vis.X1; cx++ {
i := m.At(cx, cy)
sx, sy := cam.ToScreen(cx*TileCell, cy*TileCell)
b.Blit(s, s.Frame(i%TileCols, i/TileCols), sx, sy)
n++
}
}
return n
}
$ go run ./cmd/worldc -pan -from 40
worldc 0.0.5 seed 5: 12x8 cells of 16 = 192x128 world pixels, 192x128 window at camera 0,0, 10 ticks a second
frame keys camera cells frame
1 - 0, 0 96 d2ac5a5be8e6
9 right 0, 0 96 d2ac5a5be8e6
17 right+down 0, 0 96 d2ac5a5be8e6
25 down 0, 0 96 d2ac5a5be8e6
41 left+up 0, 0 96 d2ac5a5be8e6
That switch runs the world up to one tick, freezes it there, and walks the camera
about under a scripted set of key holds. The window is left at its default here, which
is the size of the valley, and chapter 20's clamp pins a view inside the world it is a
view of, so a world exactly as big as the window leaves the camera one legal position:
five frames, all ninety-six cells, one hash five times. That equality is what the new
name has to earn before anything else. A camera that can see the whole map must draw
exactly what DrawMap drew from the origin, down to the digest, or the
culling is dropping cells that had pixels to put on the screen. The smaller-window run below is the case
where the count moves as the camera scrolls.
The Watch pointer race
Start it the way chapter 9 started worldd. The simulation goes on a goroutine with a stop channel and a done channel, the client stays on the main goroutine, and the window's own loop takes over from there.
// cmd/worldc/main.go
// shared is the client that keeps the world itself instead of a copy of
// what it said. Every field of it is written by the simulation
// goroutine and read by the window's.
type shared struct {
art *art
cam render.Camera
w *sim.World
}
func (v *shared) Draw(b *render.Buffer) {
if v.w == nil {
b.Fill(render.Void)
return
}
kind := make([]render.Kind, MapW*MapH)
for i := range kind {
t, _ := v.w.Ground.At(sim.Coord{X: i % MapW, Y: i / MapW})
kind[i] = kindOf(t)
}
b.Fill(render.Void)
render.DrawMapVia(b, render.Autotile(MapW, MapH, kind), v.art.tiles, v.cam)
for _, e := range v.w.Roster() {
if e.Kind != sim.Walker {
continue
}
sx, sy := v.cam.ToScreen(e.At.X*Tile, e.At.Y*Tile)
b.Blit(v.art.walk, v.art.walk.Frame(0, 0), sx, sy)
}
readout(b, v.art.font, frame{Tick: v.w.Tick(),
Water: v.w.Ground.Count(sim.Water), Pop: v.w.Population()})
}
// cmd/worldc/main.go — main, joining the two
v := &shared{art: a, cam: cam}
cfg.Paced = true
cfg.Watch = func(w *sim.World) { v.w = w }
go func() { done <- sim.Run(cfg, stop) }()
runWindow(v, cam, done, stop, *verify)
Nothing about that is lazy. Draw uses the autotiler from chapter 19, the
camera transform from chapter 20, the blitter from chapter 16 and the readout from
chapter 21, and it draws a correct picture of the valley on the author's screen at
sixty frames a second. The client even shuts down properly: the window closes,
close(stop) asks the world to finish, and <-done waits
for it to say that it has.
To point a detector at it, the frame loop has to run without a window, so the failure
box below drives sixty frames off a time.Ticker instead: the same
Draw, the same world, the same second of wall time, no display required.
$ go run ./cmd/worldc -shared -headless -ticks 100 (a wall-clock run: the hash depends on where the sixtieth frame fell, so yours will differ) 60 frames drawn against a world still ticking; last hash 5efe05fc4989bcc2
It runs, it finishes, it prints a hash, it exits 0, and the window version of the same client draws a valley that looks entirely correct while it does it. By the only test available without a tool, this client works.
$ go run -race ./cmd/worldc -shared -headless -ticks 100 (the second of four reports; paths shortened)
==================
WARNING: DATA RACE
Read at 0x00c0003260f0 by main goroutine:
main.(*shared).Draw()
/home/you/theworld/cmd/worldc/main.go:381 +0x45c
main.main()
/home/you/theworld/cmd/worldc/main.go:632 +0x1f6b
Previous write at 0x00c0003260f0 by goroutine 40:
theworld/internal/sim.(*World).Move()
/home/you/theworld/internal/sim/world.go:97 +0x290
theworld/internal/sim.(*World).wander()
/home/you/theworld/internal/sim/tick.go:76 +0x1ac
theworld/internal/sim.(*World).Step()
/home/you/theworld/internal/sim/tick.go:31 +0x76
theworld/internal/sim.Run()
/home/you/theworld/internal/sim/run.go:64 +0x89d
main.main.func2()
/home/you/theworld/cmd/worldc/main.go:623 +0xa7
Goroutine 40 (running) created at:
main.main()
/home/you/theworld/cmd/worldc/main.go:623 +0xb6a
==================
60 frames drawn against a world still ticking; last hash 5efe05fc4989bcc2
Found 4 data race(s)
exit status 66
Read the two halves of that report as a sentence. The window's goroutine read one
address inside Draw, on the line that turns a walker's cell into a
screen pixel. The simulation's goroutine had written the same address inside
Move, which wander calls, which Step calls
once a tick. The address is one walker's At field. Nothing is guarding
it, and the two goroutines are not taking turns.
The other three reports name three more of the same thing:
v.w itself, assigned by the callback and read at the top of
Draw; the second coordinate of the same walker; and the tick counter,
incremented by Step and read by the readout. Four addresses, one cause.
The renderer was given the world, and the world is being written to.
The interesting part is what the report permits. Step is not one write;
it is a seep, then a walk, one entity at a time, in ID order. A frame drawn while
that is in progress can show the pond after the spring flooded a cell and the walkers
before any of them moved, or walker 2 in its new cell and walker 3 in its old one.
There is no tick you could name that had that arrangement. A stale picture would be
harmless. That one is a picture of a world that never existed.
The fix is not a lock. A lock would make each read safe and still hand the renderer a moving target between reads, and it would let the drawing code decide when the simulation may run. What the renderer actually needs is a few hundred bytes, and the place to copy them is the one instant when nothing is moving.
One frame copied after one tick
Watch is called from inside sim.Run, after
Step has returned and before the next tick's timer fires. That is the only
instant in the program when the world is finished and nothing is moving, and it belongs
to the simulation's goroutine. Everything the picture needs gets copied there.
// cmd/worldc/main.go
// frame is one tick of the world as the renderer is allowed to see it:
// numbers copied out of the simulation while the simulation was between
// two ticks, and not one pointer back into it.
type frame struct {
Tick uint64
Water int
Pop int
Map *render.TileMap // never written again once it is in here
Snaps []render.Snap
Asked int // cells asked for a tile index this tick
Retiled []Retile // the ones whose answer changed
}
// observer is the client's half of the simulation goroutine. Everything
// it owns is touched by that goroutine and no other, and the only thing
// that leaves is a frame, over a channel.
type observer struct {
kind []render.Kind // this client's own copy of the ground
tiles *render.TileMap // the tile indices that copy produces
seen map[sim.EntityID]render.Snap // where each walker stood last tick
frames chan<- frame
stop <-chan struct{}
asked int // cells asked for a tile index on the last tick
retiled []Retile // of those, the ones whose answer was different
}
// tick is what sim.Run calls when a tick is finished and nothing is
// moving. It reads the world, builds one frame out of copies, and hands
// it over.
func (o *observer) tick(w *sim.World) {
o.reground(w.Ground)
fr := frame{
Tick: w.Tick(),
Water: w.Ground.Count(sim.Water),
Pop: w.Population(),
Map: o.tiles,
Snaps: o.snapshot(w),
Asked: o.asked,
Retiled: o.retiled,
}
select {
case o.frames <- fr:
case <-o.stop:
}
}
The Map field is a pointer, and it is the one place the promise in that
comment has to be kept by hand. The channel holds a single frame, and that buffer of
one is the whole coupling between the two goroutines: the world hands a tick's
picture over and goes back to ticking, and if the window is slow the world waits at
that send instead of racing ahead into memory somebody is reading. The second case
in the select covers shutdown.
snapshot is chapter 22's pairing with a filter in front of it. Each walker
gets a Snap holding the world pixel it stands on now and the one it stood
on last tick, and an entity with no entry in last tick's map is marked
Fresh instead of being given a previous position it never had.
sim.Run puts three entities in the valley: two walkers and a shrub, at
cell (2, 5). The readout says POP 3 and two animals move about the
frame, because the sheet chapter 17 drew holds one creature in four poses and no
plant at all. The shrub is in the world, in the count and in the event log, and there
is nothing to blit for it. Nothing here is waiting on one more drawing, either. A
shrub that looks like a shrub has to be grown, not posed, and growing one is a different kind of drawing than a posed walker.
// cmd/worldc/main.go
// reground brings the client's copy of the ground up to date and retiles
// what changed. A cell's tile depends on itself and its four neighbours
// and on nothing else, so one flooded cell costs five lookups and the
// other ninety-one entries stay exactly as they were.
func (o *observer) reground(g *sim.Grid) {
o.asked, o.retiled = 0, nil
if o.kind == nil {
o.kind = make([]render.Kind, g.W*g.H)
for i := range o.kind {
t, _ := g.At(sim.Coord{X: i % g.W, Y: i / g.W})
o.kind[i] = kindOf(t)
}
o.tiles = render.Autotile(g.W, g.H, o.kind)
o.asked = g.W * g.H
return
}
var flooded []int
for i := range o.kind {
t, _ := g.At(sim.Coord{X: i % g.W, Y: i / g.W})
if k := kindOf(t); k != o.kind[i] {
o.kind[i] = k
flooded = append(flooded, i)
}
}
if len(flooded) == 0 {
return
}
// A published tile map is never written to again: the renderer may
// still be holding the last one. This one is a copy with five
// entries recomputed.
next := &render.TileMap{Cols: o.tiles.Cols, Rows: o.tiles.Rows, Cell: slices.Clone(o.tiles.Cell)}
for _, i := range flooded {
cx, cy := i%g.W, i/g.W
for _, d := range [][2]int{{0, 0}, {0, -1}, {1, 0}, {0, 1}, {-1, 0}} {
nx, ny := cx+d[0], cy+d[1]
if nx < 0 || nx >= g.W || ny < 0 || ny >= g.H {
continue
}
o.asked++
was := next.At(nx, ny)
now := render.TileOf(g.W, g.H, o.kind, nx, ny)
next.Cell[ny*next.Cols+nx] = uint8(now)
if now != was {
o.retiled = append(o.retiled, Retile{CX: nx, CY: ny, Was: was, Now: now})
}
}
}
o.tiles = next
}
$ go run ./cmd/worldc -headless -ticks 1 -map
worldc 0.0.5 seed 5: 12x8 cells of 16 = 192x128 world pixels, 192x128 window at camera 0,0, 10 ticks a second
the tile map at tick 1
row 0 18 18 18 18 18 18 18 18 18 18 18 18
row 1 18 16 17 16 16 17 16 16 16 16 16 18
row 2 18 17 17 16 17 16 17 16 17 16 16 18
row 3 18 16 9 1 1 1 3 17 17 17 17 18
row 4 18 16 12 4 19 0 0 3 17 17 17 18
row 5 18 16 16 17 8 4 4 6 16 17 17 18
row 6 18 16 17 17 14 17 17 16 16 17 17 18
row 7 18 18 18 18 18 18 18 18 18 18 18 18
96 cells asked for a tile index on the client's first frame
Read that table against chapter 19's and it is the same eight rows of numbers, down to the 19 sitting at cell (4, 4) where the second open water tile won its coin toss. Chapter 19 computed it from a grid it generated for itself and saved a PNG. This one came out of a running world over a channel, and the two agree because the autotiler is a function of the ground and nothing else. Note the last line: the client's first frame pays the full ninety-six, because a client that has just started has no copy of the ground to compare against.
reground builds a new TileMap instead of editing the one it
has because the renderer may still be holding tick 39's frame while tick 40 is being
computed. Writing five entries into the map that frame points at would change a
picture that has already been handed over, which is the failure box's mistake at a
smaller scale.
The spring floods one cell. Which drawn cells can that change? The flooded cell itself, certainly. Its four edge neighbours, because a water cell picks its tile from whether its four neighbours are land, so a neighbour that is water now has a different answer to give. Nothing else on the map can see the change at all: cell (0, 0) has the same four neighbours it had a tick ago, so its tile index is the same number computed from the same inputs.
One flooded cell, five cells to ask again. On this run the five were (4, 2) with its
north, east, south and west, and two of the five gave a different answer:
1 + 4 = 5 asked, 2 changed. The other ninety-one entries of the tile map
were copied across untouched.
Write it for f cells flooded in one tick, on a map W cells across and H down:
asks ≤ 5f, against W · H for a full rebuild
Less than or equal, because two flooded cells sitting next to each other share neighbours and a cell on the edge of the map has fewer than four. On this valley that is 5 against 96, which nobody would notice. On a map 200 cells square it is 5 against 40,000, eight thousand times less work for the same picture, and it is the difference between a spring that seeps and a client that stutters every four seconds.
// cmd/worldc/main.go
// Step is called once per frame by the shell: read the keys, then take
// whatever the world has finished since the last frame.
func (v *view) Step(in *input.State) error {
if in.Pressed(input.Quit) {
return shell.ErrQuit
}
if in.Down(input.Left) {
v.cam.X -= Speed
}
// ... right, up and down, the same three lines each ...
v.cam.Clamp(MapW*Tile, MapH*Tile)
for {
select {
case fr := <-v.frames:
v.cur, v.have, v.arrived = fr, true, time.Now()
continue
default:
}
break
}
return nil
}
// Draw paints the frame. Alpha is how much of a tick has passed since
// the newest one arrived, and it stops at 1: a renderer that ran on
// past the end of the interval would be extrapolating, which is a
// guess about a tick the world has not taken.
func (v *view) Draw(b *render.Buffer) {
if !v.have {
b.Fill(render.Void)
b.DrawTextIn(v.art.font, 4, 4, "WAITING FOR TICK 1", bone)
return
}
alpha := float64(time.Since(v.arrived)) / float64(sim.TickDuration)
if alpha > 1 {
alpha = 1
}
paint(b, v.art, v.cam, v.cur, alpha)
}
// paint draws one whole frame: the ground through the camera, the
// walkers between two ticks, the readout on top. It reads a frame and
// the art, and nothing else in the program.
func paint(b *render.Buffer, a *art, cam render.Camera, fr frame, alpha float64) int {
b.Fill(render.Void)
cells := render.DrawMapVia(b, fr.Map, a.tiles, cam)
p := float64(fr.Tick) - 1 + alpha
for _, s := range fr.Snaps {
wx, wy := s.At(alpha)
sx, sy := cam.ToScreen(wx, wy)
b.Blit(a.walk, a.walk.Frame(pose(s, p), 0), sx, sy)
}
readout(b, a.font, fr)
return cells
}
The receive loop is non-blocking on purpose. A window that waited for a tick would refresh ten times a second and throw away the interpolation this volume just built. Where chapter 22's client computed alpha from a clock it was handed, this one measures it from when the newest tick showed up, which is all a receiver knows about a sender's schedule. The clamp at 1 is new: if the next tick is late, the picture holds at the position the world last confirmed instead of walking on into a step nobody has computed.
The first branch is the client's first tenth of a second. sim.Run reports
after a tick, never before one, so a window is open and asking for pixels before the
world has said anything at all. Then paint, ten lines long, taking no
argument that can change under it. That is the payment for the frame type: chapter
19's tile map, chapter 20's camera, chapter 16's blitter, chapter 22's blend and
chapter 21's screen-space readout, in the order the layers stack, reading data that
stopped moving before the function was called.
One thing inside it did change. Chapter 22 timed a two-frame walk, because two frames
were all it needed to show that the timing worked; the sheet has four, drawn as a
cycle, so pose now runs through all of them at the same sixty
milliseconds each. A whole stride takes 240 milliseconds of presentation time, which
is two and two fifths of a tick, so the foot a tick boundary lands on keeps moving
instead of repeating.
Figure 23.1 — the seam. Everything above the middle band runs on one goroutine, everything below it on another, and the only thing that crosses is a value that nobody will write to again.
Tick forty
The spring seeps every forty ticks. It has been doing that since volume 1, and until now you have read about it: a line in a log, a heartbeat saying the water count went up by one. Four seconds into a run, one square of soil at the edge of the pond becomes water, and the banks around it have to be redrawn because the pond has a different outline than it had a tick earlier.
Headless mode is how that stays checkable. There is no window and no wall clock: the loop receives one frame per tick from the same channel the window would have used, paints it at alpha 1, and hashes it. Chapter 22 established what alpha 1 means, and it is the reason this works. A frame at alpha 1 contains no blended pixel anywhere, because the blend at alpha 1 returns the newer position itself, so the picture is a function of the tick alone.
$ go run ./cmd/worldc -headless -ticks 120 -from 39 -to 41 -shot seep
worldc 0.0.5 seed 5: 12x8 cells of 16 = 192x128 world pixels, 192x128 window at camera 0,0, 10 ticks a second
tick 39 water 16 pop 3 #2 144, 48 #3 0, 0 3711b1fe2693105b
tick 40 water 17 pop 3 #2 144, 64 #3 16, 0 d2ac5a5be8e61921
cell (4,2) asked again: tile 17 became tile 11
cell (4,3) asked again: tile 1 became tile 0
5 cells asked, 2 answered differently
763 of 24576 pixels differ between tick 39's frame and tick 40's
280 in the 2 retiled cells
410 in the 4 squares a walker stood on in either frame
73 in the readout's corner {4 4 65 33}
0 anywhere else on the frame
wrote seep-mask.png: every pixel the two frames disagree about
tick 41 water 17 pop 3 #2 128, 64 #3 32, 0 1b9dbb29b1811677
120 ticks drawn, one frame each
tick 1 sha256 d87005a1035991b11534fb5a0b7562dc7488fd28776eb6ec97905ba94c5dea07
tick 20 sha256 91cee9d8470126deb94fcd12558561a3dfde8a7346263dc48ffd2e605194fe8b
tick 40 sha256 d2ac5a5be8e61921bff3fb644275d4c7ade2eb430e7119940079be5c5ba4564b
tick 80 sha256 d671b3b7cb4e33e52f9a4f8b464ee8e3690354ec5288d0d8f305a09d05c79170
tick 120 sha256 d0509924a32acfc64e24bf4c5ff454e0b7343a0788bb85cfdaf8423d402fd367
Take the two retile lines apart, because they are the shoreline redrawing itself in
numbers. Cell (4, 2) was soil and drew tile 17, the second of the two soil tiles
chapter 18 drew so a field of them would not read as graph paper. It is water now,
and its four neighbours answer land to the north, land to the east, water to the
south and land to the west: 1 + 2 + 0 + 8 = 11. Tile 11 is a water cell
with a bank on three sides and an opening south, which is exactly what a new square
hanging off the top of a pond looks like.
Cell (4, 3) is the one nobody would have thought to redraw. It was already water, it is still water, its own kind did not change at all. What changed is its north neighbour, which used to be soil and gave it a mask of 1, a single dark band along its top edge. Now the north is wet too, its mask is 0, and it draws open water. The waterline that ran along the top of that cell has moved up one square, and no code anywhere decided that. It fell out of five lookups.
The four lines under the hashes are the claim this volume can make and volume 1 could not. 763 pixels of 24,576 changed between one tick and the next, and every one of them has a name. 280 are inside the two cells that were retiled. 410 are inside the four sixteen-by-sixteen squares a walker occupied in one frame or the other. 73 are inside the rectangle the readout writes in, which is the 39 turning into a 40 and the 16 turning into a 17. And the last line reads zero: not one pixel of that frame moved for a reason the client could not account for.
Figure 23.2 — tick 40 of seed 5, 192 by 128 framebuffer pixels, shown enlarged. Ground from chapter 19's autotiler over chapter 18's tileset, the creature from chapter 17, the letters from chapter 21, all of it read from a world that was still running when the frame was taken. The second walker is up on the northern rim with the readout drawn over it, which is what drawing the interface last costs.
Figure 23.3 — the 763 pixels of Figure 23.2 that tick 39's
frame does not agree with, drawn by chapter 14's Mask. Reading the red:
the new water cell at the pond's northern edge with the old waterline showing under
it, both walkers in the squares they left and arrived at, and three digits.
The camera has been in the pipeline the whole time and has had nothing to do, because
this valley is 192 by 128 pixels and so is the window. Give the client a smaller one
and it wakes up. The run below holds tick 40 completely still and drives the camera
over it from a keyboard that is written down as a list, pushed into the same
input.State a window fills.
// internal/input/input.go — the table the keys column is printed from
var names = [NumKeys]string{"left", "right", "up", "down", "snap", "quit"}
func (k Key) String() string {
if k < 0 || k >= NumKeys {
return "?"
}
return names[k]
}
A log of which keys were held wants those keys spelled, and chapter 15 gave
Key six constants and no way to print one.
$ go run ./cmd/worldc -pan -from 40 -vw 128 -vh 96
worldc 0.0.5 seed 5: 12x8 cells of 16 = 192x128 world pixels, 128x96 window at camera 32,16, 10 ticks a second
frame keys camera cells frame
1 - 32, 16 48 9918d204dc1f
9 right 56, 16 54 8bf0e4049a24
17 right+down 64, 32 48 8f711b5e93f3
25 down 64, 32 48 8f711b5e93f3
41 left+up 16, 0 48 3d5c3c1f3185
Four things in five lines. The camera starts centred, so the rock rim falls outside the frame. Eight frames of held right move it 24 pixels and the cell count climbs from 48 to 54, because a camera standing between two cell boundaries needs a partial column at each edge and reaches nine columns to fill eight columns' worth of screen. Frames 17 and 25 have the same hash: the camera hit the map's south-east limit at (64, 32) and holding the key down after that does nothing, because chapter 20's clamp refuses to show ground the map does not have. Then sixteen frames of left and up together, which would put the camera at (16, −16); it lands at (16, 0), stopped by the same clamp at the northern edge.
Then the real thing. A display, a window manager, and the shell asking for pixels on a schedule nobody in this program controls.
$ go run ./cmd/worldc -ticks 60 -until 43 -from 39 -to 41 -verify (wall-clock figures measured on the author's machine; your run will differ) worldc 0.0.5 seed 5: 12x8 cells of 16 = 192x128 world pixels, 192x128 window at camera 0,0, 10 ticks a second frame 1: sent 98304 bytes, read back 98304, 0 differ; the screen holds the numbers we handed it worldc: the world stopped cleanly the window drew 18 frames while the world went from tick 39 to tick 41 alpha at the first eight: 0.000 0.166 0.340 0.500 0.666 0.833 0.000 0.167 distinct pictures: 18; frames drawn exactly on a tick boundary: 0
The window is 768 by 512 on the author's screen, four screen pixels to one of ours,
and the valley fills it. The readout counts. The two animals cross the soil at a
walking pace with their legs going, and they do it on six pictures per tick out of a
simulation that decided where anything was three times in that span. The first line
is chapter 15's read-back check, still passing: 98,304 bytes went to
WritePixels, 98,304 came back, none of them changed on the way.
The last line is chapter 22's awkward arithmetic arriving in a real window. Eighteen distinct pictures, and not one of them is a frame the contract hashes. Landing on a tick means alpha exactly 1, and a display refresh will not coincide with a tick deadline to the nanosecond. So the frames a person watches and the frames a test can name are two sets that never meet, and that is the arrangement rather than a hole in it: what the contract binds is the drawing code, and the drawing code is what made all eighteen.
// cmd/worldc/main_test.go
// TestAPublishedMapIsNeverWrittenAgain checks the promise the renderer
// depends on: a frame handed over stays exactly as it was handed over,
// however many ticks the world takes afterwards.
func TestAPublishedMapIsNeverWrittenAgain(t *testing.T) {
got := run(t, 45)
before := append([]uint8(nil), got[38].Map.Cell...) // tick 39's ground
for i, c := range got[38].Map.Cell {
if c != before[i] {
t.Fatalf("tick 39's map changed at cell %d after publication", i)
}
}
if got[38].Map == got[39].Map {
t.Error("tick 39 and tick 40 share one tile map: the seep wrote into a published one")
}
if got[40].Map != got[39].Map {
t.Error("tick 41 copied a map that did not change; a tick with no seep should share it")
}
}
$ go test -count=1 -v ./cmd/worldc/
=== RUN TestTheSeepFrameMatchesItsHash
--- PASS: TestTheSeepFrameMatchesItsHash (0.00s)
=== RUN TestOneSeepRetilesFiveCells
--- PASS: TestOneSeepRetilesFiveCells (0.00s)
=== RUN TestAPublishedMapIsNeverWrittenAgain
--- PASS: TestAPublishedMapIsNeverWrittenAgain (0.00s)
=== RUN TestValleyMatchesItsHash
--- PASS: TestValleyMatchesItsHash (0.00s)
PASS
ok theworld/cmd/worldc 0.005s
$ go test -count=1 -race ./...
? theworld/cmd/exportart [no test files]
? theworld/cmd/mkatlas [no test files]
? theworld/cmd/mkfont [no test files]
? theworld/cmd/palcheck [no test files]
ok theworld/cmd/worldc 1.045s
ok theworld/internal/input 1.014s
ok theworld/internal/render 1.978s
? theworld/internal/shell [no test files]
ok theworld/internal/sim 1.083s
The last two clauses of that test are the interesting pair. Tick 39 and tick 40 must not share a tile map, because the seep changed the ground and the older frame must keep the ground it was drawn with. Tick 40 and tick 41 must share one, because nothing flooded and copying ninety-six bytes to say so would be work with no question behind it. Sharing is safe precisely when nothing writes, and the two assertions together say when that is.
The fourth test in that listing is chapter 19's, riding along: rewriting the client
around a camera meant rewriting the main that used to assert the valley's
hash, so the assertion moved into this file and the map seed 5 draws stays pinned to
the same sixty-four characters it was pinned to four chapters ago.
The -race line is the failure box's verdict on the finished client. The
same two goroutines, the same three counts read out of a world that is still running,
and the detector has nothing to report, because the only thing crossing between them
is a value that was finished before it was sent.
quiet-tileset.png at tick 40
Chapter 18's optional atlas leaves more soil unmarked and groups the rock highlights into wider ledges. Its authored comparison held a pond and two walkers still. The client itself can be asked to draw with that sheet. The terrain file changes; the seed, tile IDs, creature positions and simulation rules stay put.
Add the atlas-path option to the client with the code below. The default remains assets/tiles/valley-tileset.png, preserving the original picture and its hashes.
In cmd/worldc/main.go, replace loadArt with these two functions. Keep the existing fmt and path/filepath imports. The wrapper keeps callers on the default atlas; the second function resolves the selected path and checks its dimensions before loading the unchanged walker and font.
func loadArt(root string) (*art, error) {
return loadArtWithTiles(root, "assets/tiles/valley-tileset.png")
}
func loadArtWithTiles(root, tileset string) (*art, error) {
path := tileset
if !filepath.IsAbs(path) {
path = filepath.Join(root, path)
}
tiles, err := render.LoadSheet(path, render.TileCell)
if err != nil {
return nil, err
}
if tiles.Pix.W != 4*Tile || tiles.Pix.H != 5*Tile {
return nil, fmt.Errorf("tileset %s is %dx%d; want 64x80 (four columns, twenty cells)", path, tiles.Pix.W, tiles.Pix.H)
}
walk, err := render.LoadSheet(filepath.Join(root, "assets/sprites/walker.png"), Tile)
if err != nil {
return nil, err
}
font, err := render.LoadFont(filepath.Join(root, "assets/sprites/font6x8.png"), 6, 8, ' ')
if err != nil {
return nil, err
}
return &art{tiles: tiles, walk: walk, font: font}, nil
}
In main, add this flag beside -replay, before flag.Parse():
tileset := flag.String("tileset", "assets/tiles/valley-tileset.png", "the 64x80 terrain atlas; changes drawing, not the world")
Replace a, err := loadArt(".") with the call below. Keep the following error check unchanged.
a, err := loadArtWithTiles(".", *tileset)
go run ./cmd/worldc -headless -ticks 41 -from 39 -to 40 -shot current
go run ./cmd/worldc -headless -ticks 41 -from 39 -to 40 -tileset assets/studies/quiet-tileset.png -shot quiet
go run ./cmd/worldc -tileset assets/studies/quiet-tileset.png
Compare current-t040.png with quiet-t040.png. Each is
the client's 192 by 128 framebuffer at alpha 1, where the interpolated position
reaches the current tick. The windowed command uses the same alternate atlas
while the world ticks and the walkers animate. Press Escape to close it; use
-vw 128 -vh 96 if you want room to pan the camera.
Only the art loader receives the path. loadArtWithTiles loads a
sheet with sixteen-pixel cells and checks that it is four columns by five rows
before a frame can use it. A walker sheet also has sixteen-pixel cells, but its
four columns and one row cannot supply the twenty terrain IDs. The loader
rejects that mistake instead of silently drawing missing cells.
Figure 23.3 — the same tick as figure 23.1, drawn by
worldc with the optional atlas. The pond, readout and walker
positions agree; fewer soil marks compete with the creature.
Open the original frame and
the quieter frame
at their native resolution to inspect single pixels. These are separate
captures of the same deterministic run, not a repainted screenshot.
The optional tick-39 frame also ships as a fixture. The client tests redraw both ticks and compare their pixels with the saved PNGs, while the existing tests retain the original atlas's hashes. Drawing either version must leave the published tile map and walker snapshots untouched. Keep the two sets of pictures distinct: an art revision changes a frame's hash even when nothing about the world changes.
Copies at the seam
Chapter 22 ended on a rule about direction: truth may flow into the picture, and the picture may never flow back into the truth. That rule was about a number, the blended pixel a walker is drawn at mid-tick, and about not letting it decide anything. The same rule turns out to govern memory, and the goroutine version of it is stricter, because the thing that must not flow backwards is not a decision but a write.
Two goroutines that share a value are in a negotiation about time. Either they take
turns, which means one of them waits, or they do not, which means neither can say what
the other is in the middle of. Handing over a copy ends the negotiation by removing the
thing being negotiated over. After the send the simulation has a world nobody else can
see, the window has a frame nobody else will write to, and the channel is the one event
both of them agree on. That is the send-happens-before-receive guarantee chapter 9
leaned on after <-done, used ten times a second instead of once.
The cost is the copy, and it stays small because the renderer never needed the world. It needs what a picture is a function of: a tick number, three counts, ninety-six bytes of tile indices and two coordinate pairs per walker. A larger world does not change the boundary; a picture only ever needs what is on screen. Put the simulation at the far end of a network connection and the same seam still fits: the copy travels over a socket instead of a channel, and the renderer cannot tell.
The other idea to carry forward is the five cells. A tile index is a function of a cell and its four neighbours, so when one cell changes, exactly the entries whose inputs moved need recomputing, and the entries whose inputs did not are already correct. Nothing about that is specific to tiles. It is the same reasoning that decides which part of a screen to repaint, which downstream value to recompute in a spreadsheet, and which build outputs are stale. The whole trick is being able to say, in one sentence, what each derived value depends on. Chapter 19 made that sentence true of the tile map by refusing to let the renderer look at anything except a cell and its neighbours, and this is where that decision pays.
Determinism came from ordering, not from timing, and that deserves a plain statement because it is easy to expect the opposite. The headless run has two goroutines, a buffered channel, and a scheduler nobody controls, and it produces the same hash on every run of every machine. It does that because the channel delivers frames in the order they were sent, and each frame is a complete answer that does not depend on when it is read. Concurrency is not the enemy of a reproducible run. Shared mutable state is.
The running seed-5 window
Volume 1 ended with a world in a box, ticking, writing down everything that happened to it, and readable only as testimony. This volume set out to make it visible, and the route it took was long on purpose. A rectangle of numbers and the arithmetic to address one. Three drawing operations and a clip rectangle that refuses everything else. A picture reduced to a hash, so a change of one pixel could be caught. A window borrowed for three jobs and none of its drawing. Sprites, and the pixels a sprite must leave alone. Then two chapters at a pixel editor, because a palette and a tileset are engineering as much as the loader that reads them. A map that draws itself from four bits per cell. A camera that decides which part of the world the glass holds. Letters, built out of the blitter that already existed. Six frames between two ticks, and the rule that keeps them from lying. And now all of it at once, reading a world that is still running.
What is on the screen is a small place. A pond, a rim of stone, two animals and a number that goes up every tenth of a second. What is true about it is the part to keep. The picture is a function of the world's state and the art on disk, so two machines draw the same frame. The world is never touched by the thing drawing it, so a frame is always a picture of a moment that happened. And the whole of it replays from one seed, which means the pond you watched fill can be watched again, pixel for pixel, in a year.
- Put a simulation on its own goroutine and a frame loop in a window's, and name the one value that is allowed to cross between them.
- Given a race report naming
Drawon one side andMoveon the other, say which field it is about and what kind of wrong picture it permits. - Say why a lock around the world would still not be enough, and what a renderer actually needs copied out of a simulation.
- Given a cell of ground that just changed, name the five cells whose tile index has to be asked again, and say why the other ninety-one do not.
- Explain why a tile map handed to the renderer is replaced instead of edited, and write the test that catches an edit.
- Hash a client's frames in CI without a display, and say what alpha the hashed frames were taken at and why that number is the only one that can be.
Exercise 1: watch a different world. Run
-headless -seed 9 -from 39 -to 41 and predict, before you look, how
many cells the seep at tick 40 retiles and how many of them change.
Five asked, because the arithmetic does not know what seed it is running. Different cells, though: seed 9 floods (1, 4), which drew soil tile 16 and now draws 13, and the cell it changes is (2, 4), which drops from 12 to 4 because the bank on its western side has become open water. Two of five again, and for the same reason both times: the flooded cell had exactly one wet neighbour to tell.
The stronger check is the tick 1 map. Chapter 19's exercise 3 printed seed 9's tile table from a grid it generated on the spot; the client prints the same table from a world that is running. If those two disagree by one number, something between the simulation and the tile map has drifted, and the number that disagrees names the cell to look at.
Exercise 2: take the copy away, one field at a time. In
frame, replace Snaps []render.Snap with a
[]*sim.Entity taken straight from Roster, keeping
everything else. Predict what go run -race ./cmd/worldc -headless
says, and what the picture loses.
The detector fires on e.At again, this time inside
paint, because Roster hands back the pointers the world
holds and copying a slice of pointers copies no positions at all. The frame looks
like a copy and is a window onto the same entities.
The picture loses more than safety. An entity knows where it is and has no memory of where it was, so there is no previous position to blend from, and every walker teleports a cell at a time at ten times a second. The failure box's client had that defect permanently and no amount of locking would have fixed it: the pair of positions chapter 22 needs does not exist anywhere in the simulation. It is made at the seam, by the code that remembers what it sent last time.
Exercise 3: delete the clamp on alpha. Take the
if alpha > 1 out of view.Draw, then run
-ticks 5 with a window and leave it open for ten seconds. Predict what
happens after the world stops, and why the clamped build does something else.
Once the fifth tick lands, arrived stops being updated and
time.Since keeps growing, so alpha climbs past 1 with nothing to
stop it, and the blend goes on evaluating. Walker 2's fifth tick took it from
world pixel (144, 80) to (144, 64), so it is drawn at
y = 80 + round(−16 × α): at alpha 6 that is
−16, off the top of the window, and alpha climbs by 10 a second. Both
animals are gone inside a second, walking the direction of their last step
forever with their legs going the whole way.
With the clamp, every frame after the world stops is byte-identical: the walkers stand exactly where the last tick put them and the picture holds. Alpha 1 means the newer position itself, which is a place the simulation named. Any alpha above 1 is arithmetic about a tick that has not happened, and the fact that the arithmetic runs without complaining is what makes the one-line guard necessary.
Watch the window for a minute and the thing you keep noticing is the walking, not the pond. Two animals cross the valley in straight lines, turn at random, walk into the water's edge and stop dead because a rule refused the move. Nothing drifts. Nothing startles, nothing follows anything, nothing bunches up when the two of them meet, and the water they are walking around has never once rippled.
Every position in this world is a whole cell arrived at in one step, and the smoothness on the glass is the renderer's polite fiction over the top of it. The client draws that fiction without letting it flow back into the world.