Motion in Three Vectors
The step that cannot be pushed
The amber mote crossed The Hollow on one vector added fifty times: 2.40 across and 1.80 down, tick after tick, until it landed on the pixel the arithmetic promised. Give that mote a gust for ten ticks and the model breaks. The only motion it owns is the step it received before the loop started, and replacing that step discards the motion that was already there.
Every moving thing in this world is a body of three vectors, and one tick applies them in one order: the velocity takes on the acceleration, the position takes on the velocity that resulted, and the acceleration returns to zero. A gust can then change how a leaf is moving without erasing the motion the leaf already had. A falling body can keep the speed it picked up one tick earlier.
The tool for holding such a quantity is already built. A vec.Vec2 is two
numbers with arithmetic of their own, and this chapter spends three of them on every
moving thing: where the thing is, how fast it is going, and what this tick's pushes
add up to. Three vectors is the easy part. The order is the part that has to be nailed
down, because the same three lines can be written six ways and several of them look
reasonable.
The chapter defends that order in numbers. Four ticks of one body are worked out by hand, then computed. The swapped order looks plausible and sends a tethered body 2,364 pixels from a 192-pixel window. The missing clearing line throws a falling body out of the window on tick 11 instead of tick 22. Then the same update runs twice on floating-point values and proves that replay is an ordering problem, not a fear about decimals.
Four ticks, worked in plain numbers
One body, to follow all the way through the chapter. It starts at world pixel (24, 8), the unit the renderer already thinks in: sixteen of them to a cell, so the body starts a cell and a half in from the west rim and half a cell down from the top. It is moving two pixels east per tick, which at ten ticks a second is twenty pixels a second, a little over one cell. And one push acts on it: half a pixel per tick per tick, straight down.
Read that last unit slowly, because it is the one that is actually new. The velocity is pixels per tick: how far the body travels in one tick. The acceleration is pixels per tick per tick: how much the velocity changes in one tick. A push of 0.5 does not move the body half a pixel. It makes the body half a pixel per tick faster, and the moving is done by the velocity afterwards.
Now walk it by hand. Each tick: add the push to the velocity, add the new velocity to the position, then throw the push away.
| tick | pushed by | velocity after | position after | fell this tick |
|---|---|---|---|---|
| 0 | — | 2.0, 0.0 | 24.0, 8.0 | — |
| 1 | 0.0, 0.5 | 2.0, 0.5 | 26.0, 8.5 | 0.5 |
| 2 | 0.0, 0.5 | 2.0, 1.0 | 28.0, 9.5 | 1.0 |
| 3 | 0.0, 0.5 | 2.0, 1.5 | 30.0, 11.0 | 1.5 |
| 4 | 0.0, 0.5 | 2.0, 2.0 | 32.0, 13.0 | 2.0 |
Three things are visible in that table. The sideways column never changes: nothing pushes east or west, so the velocity's x stays at 2 and the position's x climbs by 2 a tick forever. The downward velocity climbs in equal steps, one push per tick. And the distance fallen per tick is not equal at all, because it is the velocity, which grew. Five pixels of fall in four ticks, from a push that only ever contributed half a pixel per tick per tick. That curve is what falling looks like, and nobody wrote a curve.
Write the table's arithmetic once. Take t as the tick being computed, and read pt as "the position at the end of tick t":
vt = vt−1 + at
pt = pt−1 + vt
The second line uses vt, the velocity the first line just made, not the one the body arrived with. That subscript is the whole argument of the next section.
A physics text writes those lines with a timestep in them: velocity gains a × Δt, position gains v × Δt. Here Δt is one tick, always, because volume 1 made the tick the world's only unit of when, and multiplying by one changes nothing. So the timestep vanishes from the code and reappears in the units: a rate you know in pixels per second is divided by the tick rate once, when you write the law down, and stored as pixels per tick. Ten ticks a second turns 20 pixels a second into the 2.0 in the table.
One formula names the table's pattern. Under a constant push g, starting from rest, the distance covered after n ticks is the sum of g, 2g, …, ng:
fall(n) = g × n(n+1) / 2
At g = 0.5 and n = 4 that is 0.5 × 10 = 5 pixels, which is the 13.0 in the last row minus the 8.0 it started at. At n = 20 it is 105 pixels: a body dropped at the top of a 128-pixel window is off the bottom of it two seconds later.
// internal/field/vec.go
package field
import "theworld/internal/vec"
// Vec2 is the vector type from package vec, named here so the laws in
// this package read without a prefix.
type Vec2 = vec.Vec2
// internal/field/body.go
// Package field is the moving half of the world: bodies, and the pushes
// that act on them. It knows what a tick is, in the only sense that
// matters here: a tick is one call to Step.
package field
// Body is one moving thing, held as three vectors: where it is, how
// fast it is going, and what this tick's pushes have added up to so
// far. Position is in world pixels, the unit the renderer already
// uses; velocity is world pixels per tick; acceleration is world
// pixels per tick per tick.
type Body struct {
Pos, Vel Vec2
acc Vec2 // this tick's pushes, and no other tick's
}
// Push adds one push to whatever this tick has already piled onto the
// body. Adding, never assigning: two callers pushing the same body in
// one tick both get their say.
func (b *Body) Push(a Vec2) { b.acc = b.acc.Add(a) }
// Acc reports what has been pushed onto the body so far this tick.
// Nothing outside this package can write it.
func (b *Body) Acc() Vec2 { return b.acc }
// Step is the update, and the order of these three lines is the law of
// the volume: the velocity takes on this tick's acceleration, the
// position takes on the velocity that resulted, and the acceleration
// goes back to zero because it belonged to the tick that just ended.
func (b *Body) Step() {
b.Vel = b.Vel.Add(b.acc)
b.Pos = b.Pos.Add(b.Vel)
b.acc = Vec2{}
}
The first file is three lines and one idea. The vector type belongs to
internal/vec, where the previous chapter built it, and this package
gives it a second name with a type alias: field.Vec2 and
vec.Vec2 are the same type, not two types that convert, so a vector
made anywhere in the volume can be handed to anything else in it. The prefix is the
only thing that changed.
Two of the three vectors on Body are public and one is not, and the
difference is the chapter in miniature. Anything may read or set a body's position
and velocity, because those describe the body. The accumulator can only be added to, by
Push, and only emptied by Step, because it describes one
tick: if code outside the package could assign to it, the clearing line would
guarantee nothing. Push adds instead of assigning, so two callers
pushing the same body in one tick both get their say and neither overwrites the
other. Everything else this package grows will be code that computes a
Vec2 and hands it to Push.
// cmd/bodies/main.go — the hand table, run
// gravity is the one push in this chapter: half a world pixel per tick
// per tick, straight down.
var gravity = field.Vec2{X: 0, Y: 0.5}
// start is the body the hand table follows.
func start() field.Body {
return field.Body{Pos: field.Vec2{X: 24, Y: 8}, Vel: field.Vec2{X: 2, Y: 0}}
}
func table(ticks int) {
b := start()
fmt.Println(" tick pushed velocity after position after")
fmt.Printf(" %4d %15s %15s %20s\n", 0, "-", vec(b.Vel), vec(b.Pos))
for t := 1; t <= ticks; t++ {
b.Push(gravity)
pushed := b.Acc()
b.Step()
fmt.Printf(" %4d %15s %15s %20s\n", t, vec(pushed), vec(b.Vel), vec(b.Pos))
}
}
$ go run ./cmd/bodies -mode table
tick pushed velocity after position after
0 - 2.000, 0.000 24.000, 8.000
1 0.000, 0.500 2.000, 0.500 26.000, 8.500
2 0.000, 0.500 2.000, 1.000 28.000, 9.500
3 0.000, 0.500 2.000, 1.500 30.000, 11.000
4 0.000, 0.500 2.000, 2.000 32.000, 13.000
Row for row, the table you worked by hand. That agreement is the only reason to run a first version on numbers small enough to check: the same code will soon be producing positions like 63.428194040359003, and there will be no way to tell a correct one from a plausible one by looking. The four rows above are the calibration.
Terrain, entities and every law volume 1 wrote work in cells, as whole-number
Coord values, and they should stay that way: a cell either holds water
or it does not, and no rounding decides it. Bodies work in pixels, as floats, because
a thing that moves 2.4 pixels a tick has nowhere to be on a grid of whole cells.
Both live in the same world, and the conversion between them is the multiplication
by sixteen the client already does. When a body needs to ask the ground a question,
it converts to a cell and asks; the answer comes back as a fact about a cell.
The velocity line first
Swap the first two lines and everything still compiles, still runs, still looks like motion. The body moves on the velocity it arrived with, and only afterwards learns about this tick's push. It is a defensible-looking arrangement, it appears in a great many textbooks under a respectable name, and it is the one to be able to argue against from evidence.
// internal/field/body.go — kept so the two orders can be run against
// each other
// StepLagging is Step with the first two lines swapped: the position
// moves on the velocity the body arrived with, before this tick's
// acceleration reaches it.
func (b *Body) StepLagging() {
b.Pos = b.Pos.Add(b.Vel)
b.Vel = b.Vel.Add(b.acc)
b.acc = Vec2{}
}
$ go run ./cmd/bodies -mode orders
velocity first, then position position first, then velocity
tick position speed position speed
1 26.000, 8.500 0.500 26.000, 8.000 0.500
2 28.000, 9.500 1.000 28.000, 8.500 1.000
3 30.000, 11.000 1.500 30.000, 9.500 1.500
4 32.000, 13.000 2.000 32.000, 11.000 2.000
the gap is 2.000 pixels after 4 ticks and opens by half a pixel per tick:
the second body's position on tick n is the first body's position on tick n-1
Both bodies have the identical speed at every tick, and the right-hand one is always exactly one tick behind in where that speed has taken it: 8.5 on tick 2 against 8.5 on tick 1, 11.0 on tick 4 against 11.0 on tick 3. That is the arrangement's whole character with a push that never changes. Every tick it spends the velocity from before the push landed, so a body pushed on tick 1 does not respond until tick 2, and the delay is one tick forever.
A permanent one-tick delay sounds survivable, and with gravity it is. The trouble starts the moment a push is computed from where the body is standing, which is what most of the pushes in this volume do. Then a stale position produces a stale push, the stale push produces a stale velocity, and the staleness has somewhere to accumulate.
// cmd/bodies/main.go
// middle is the point the tether pulls toward.
var middle = field.Vec2{X: ViewW / 2, Y: ViewH / 2}
// tether is a push computed from where the body is standing: toward the
// middle of the window, in proportion to how far out it has drifted.
func tether(b field.Body) field.Vec2 { return middle.Sub(b.Pos).Scale(0.02) }
$ go run ./cmd/bodies -mode held -ticks 400
velocity first, then position position first, then velocity
tick distance out speed distance out speed
100 4.208 6.804 11.380 18.200
200 47.859 0.228 342.347 8.630
300 5.814 6.797 244.772 127.761
400 47.664 0.456 2364.121 123.100
furthest out in 400 ticks: 48.120 and 2364.121, from a start of 48.000
One body on a tether, started 48 pixels out from the middle and let go. Under this volume's order it swings: out to 48, in through the middle at speed, back out to 48, and after 400 ticks the furthest it has ever been is 48.120, a tenth of a pixel more than it started with. Under the swapped order it is still swinging too, and the swings are getting wider: 342 pixels out at tick 200, 2,364 at tick 400, moving 123 pixels in a single tick. The window is 192 pixels across.
The mechanism is the delay, compounding. Coming inward, the tether should already be easing off as the body closes on the middle, but the swapped order computes the push from a position the body has left, so the pull it gets is the one for further out, slightly too strong. Going outward, the same lag makes the restraining pull slightly too weak. Each tick hands the body a little more speed than it should have, in the direction it was already going, and 400 ticks of a little more compounds into a body that has escaped. Doing the velocity first spends this tick's push inside this tick, and the small error it still makes changes sign as the body swings, so it cancels instead of piling up.
Neither arrangement is exact. Both are a stack of straight-line steps standing in
for a smooth curve, and both are a little wrong at every tick. What separates them
is what happens to the wrongness over a long run, and this world runs for months.
The order in Step is the one that holds a swinging body inside a tenth
of a pixel of where it started.
Figure 25.1 — the three lines of one tick, with tick 2's own numbers in them. Line 2 reads what line 1 wrote, and line 3 leaves the body ready to be pushed by something else entirely.
The clearing line
Position and velocity describe the body. It has them while it sits still, it keeps
them between ticks, and asking where a body is at tick 4,312 is a sensible question.
Acceleration is a different kind of quantity: it is the total of what pushed on this
body during one particular tick, and outside that tick the question has no answer. The
third line of Step is that difference, written down. Leave it out and the
accumulator stops meaning "this tick's pushes" and starts meaning "every push this
body has ever had", which the body then obeys.
// internal/field/body.go — Step with the clearing line left out
func (b *Body) StepKeeping() {
b.Vel = b.Vel.Add(b.acc)
b.Pos = b.Pos.Add(b.Vel)
}
$ go run ./cmd/bodies -mode fall (correct) tick position this tick's a frame 1 26.000, 8.500 0.000, 0.500 d9d5f95560c1 2 28.000, 9.500 0.000, 0.500 dc73177901a1 4 32.000, 13.000 0.000, 0.500 eacc8af9fd37 8 40.000, 26.000 0.000, 0.500 1aac07759997 12 48.000, 47.000 0.000, 0.500 899e9567f4b9 16 56.000, 76.000 0.000, 0.500 89e3f0494b92 20 64.000,113.000 0.000, 0.500 610d98b55b52 off the bottom of the window on tick 22, at y = 134.5
$ go run ./cmd/bodies -mode fall -keep
tick position this tick's a frame
1 26.000, 8.500 0.000, 0.500 d9d5f95560c1
2 28.000, 10.000 0.000, 1.000 dc73177901a1
4 32.000, 18.000 0.000, 2.000 7b3e8103eb10
8 40.000, 68.000 0.000, 4.000 99b29f7afb59
off the bottom of the window on tick 11, at y = 151.0
The body leaves the window on tick 11 instead of tick 22, having fallen 143 pixels
in the time the correct one falls 33. Nothing was pushing it harder: the same
constant 0.5 is handed to Push on every tick of both runs. So read the
column that says what the body actually had. It reports 0.5 forever in the first run
and 0.5, 1.0, 2.0, 4.0 in the second, doubling every time the tick number doubles,
which is 0.5 added once per tick to a field that never empties. On tick 8 the leaf is
being accelerated by eight ticks' worth of gravity at once, seven of which were
already spent.
The frame hashes are the part to sit with. Tick 1 is d9d5f95560c1 in
both runs, because on the first tick there is nothing accumulated yet and the two
arrangements agree exactly. Tick 2 is dc73177901a1 in both, and that
one is a coincidence with a cause: the correct body is at y = 9.5 and the
broken one at y = 10.0, and both round to the same whole pixel, so the two
pictures are identical to the byte. The hashes come apart at tick 4 and never agree
again.
Two ticks of agreement is not much of a hiding place, and a wrong physics update will not usually give you even that. The point is that it gave you some. The broken run is not noisy or unstable; it is smooth, it is plausible, and it is perfectly reproducible: run it a thousand times and it puts the leaf off the bottom of the window on tick 11 every single time. Reproducibility says a run is the same run. It says nothing at all about whether the run is right, and the next half of this section is about not confusing the two.
The state hash
Volume 1 built a world whose every state is reachable by a tick number and provable by replaying its log, and it did that with integers. Cells are whole numbers, entity coordinates are whole numbers, and the seeded stream produces the same draws in the same order on any machine. This chapter has just introduced numbers with fractions into the world's laws, and the folklore about those is discouraging: floats drift, floats are approximate, floats give different answers on different machines. If any of that were true as stated, the replay contract would end here.
It is not true as stated. Floating-point addition is specified to the bit: given two particular values, every conforming processor returns one particular sum, and a Go compiler may not reassociate your arithmetic to make it faster. Two runs that perform the same additions on the same values in the same sequence get the same answers. All of the drift in the folklore lives in that last clause.
Go's specification lets an implementation fuse a multiplication and an addition into
a single machine instruction, keeping the product's extra precision instead of
rounding it first. That can move the last bit, and whether it happens depends on the
processor, so it is the one arithmetic difference that can appear between two
machines running identical code. It never appears between two runs on one machine,
so nothing on this page is troubled by it. Where a value has to agree
across machines, an explicit conversion forces the intermediate rounding and blocks
the fusion: t := float64(a * b); sum := t + c. Nothing in
Step multiplies at all.
// cmd/bodies/main.go
// state is SHA-256 over the numbers themselves: every body's position
// and velocity, as the bits the machine holds them in. A frame hash
// answers whether two runs look the same; this answers whether they
// are the same.
func state(bodies []field.Body) string {
h := sha256.New()
var buf [8]byte
for _, b := range bodies {
for _, f := range []float64{b.Pos.X, b.Pos.Y, b.Vel.X, b.Vel.Y} {
binary.BigEndian.PutUint64(buf[:], math.Float64bits(f))
h.Write(buf[:])
}
}
return hex.EncodeToString(h.Sum(nil))[:16]
}
// advance runs one swarm for one tick: the tether first, then whatever
// constant pushes this run applies, in the order it was given them.
func advance(bodies []field.Body, pushes []field.Vec2) {
for i := range bodies {
bodies[i].Push(tether(bodies[i]))
for _, p := range pushes {
bodies[i].Push(p)
}
bodies[i].Step()
}
}
$ go run ./cmd/bodies -mode swarm
seed 7, 24 bodies, 400 ticks, run twice
tick run 1 frame run 2 frame run 1 numbers run 2 numbers
1 ab3baf07877d ab3baf07877d 853d2457ce69c2a6 853d2457ce69c2a6
10 9bf47983fcab 9bf47983fcab a2ccb47bd60aba50 a2ccb47bd60aba50
100 7f4fc1c2519c 7f4fc1c2519c e68c58bcebeb42be e68c58bcebeb42be
400 e26f340169c2 e26f340169c2 0fc375fcf682483c 0fc375fcf682483c
all 400 frame hashes equal: true
all 400 number hashes equal: true
body 0 starts at y = 63.428194040359003
and on the next call to swarm, y = 63.428194040359003
Twenty-four bodies, each placed and launched from the world's seeded stream, each carrying positions like 63.428194040359003 that no one could type by hand, all of them pulled by a tether that is recomputed from a fresh position every tick. Four hundred ticks, 9,600 body updates, tens of thousands of floating-point additions, and the two runs agree on every one of the 400 frame hashes and every one of the 400 number hashes. The second hash is the stronger claim: a frame hash only sees whole pixels, while the number hash is taken over the raw bits of every position and velocity, so it notices a disagreement in the seventeenth digit that no picture could show.
Nothing exotic is holding that up. The bodies live in a slice, so they are stepped in index order. The pushes are applied in the order the slice lists them. The seed feeds one stream, drawn from in one sequence. There is no map anywhere in the loop, and no law reads a clock. Same inputs, same operations, same order, same bits.
$ go run ./cmd/bodies -mode reorder
the three pushes:
gravity 0.000, 0.500
breeze 0.120, -0.200
swirl -0.050, 0.100
every order they can be added in, and the y that comes out:
gr br sw y = 0.40000000000000002 bits 3fd999999999999a
gr sw br y = 0.39999999999999997 bits 3fd9999999999999
br gr sw y = 0.40000000000000002 bits 3fd999999999999a
br sw gr y = 0.40000000000000002 bits 3fd999999999999a
sw gr br y = 0.39999999999999997 bits 3fd9999999999999
sw br gr y = 0.40000000000000002 bits 3fd999999999999a
distinct answers from those six orders: 2
distinct answers from a thousand walks over a map of the same three: 2
Three pushes that a leaf might plausibly feel at once, and the y component of their
total depends on which of them you add first. Two of the six orders produce
3fd9999999999999 and the other four produce
3fd999999999999a: one step apart in the last bit, and neither of them
exactly 0.4, which is not a number binary floating point can hold at all. Each
addition rounds its result to the nearest value the format has, and rounding a
different intermediate total rounds by a different amount.
The last line raises the alarm. Put the same three pushes in a map, walk the map a thousand times adding them up, and two different totals come back, because Go deliberately hands map entries out in an order it chooses fresh. That is chapter 5's roster reshuffling itself, arriving in a place you would not think to look for it: not deciding who moves first, just deciding which of two sums a body gets. Chapter 10 drew the boundary and named what may cross it, seed and tick count, and listed map order among the things that may not. A push accumulator that iterates a map has walked that fact straight back across the line.
$ go run ./cmd/bodies -mode clash
seed 7, 24 bodies, the same three pushes, added in two orders
tick order A frame order B frame order A numbers order B numbers
1 6b31eddcfb8b 6b31eddcfb8b 1aef2cd9d81a0893 0950cb717fcb178c
2 d4c878025213 d4c878025213 12b667346a7893a7 7d5b2f0490dfd81c
10 351b02233d2c 351b02233d2c 70bfad10d10fab8c db4fb5b822860c02
400 e26f340169c2 e26f340169c2 af3752fb4bd4389e 2e714bcd7f54e086
first tick whose numbers differ: 1
first tick whose picture differs: never, in 400 ticks
tick widest gap between the two runs, in world pixels
1 1.421e-14
10 1.421e-14
100 9.948e-14
1000 3.587e-13
10000 3.111e-12
One seed, one swarm, the same three pushes, applied gravity-breeze-swirl in the left column and swirl-breeze-gravity in the right. The number hashes disagree on tick 1 and never agree again: the two runs are different histories from their first instant. The frame hashes are identical for all 400 ticks, and would stay identical for a great many more, because the disagreement between the two runs starts at 1.4 × 10−14 of a pixel. That figure is not arbitrary either. It is one step in the last bit of a number near 64, which is where these bodies are, and a pixel is 1.0.
So a reordered accumulator gives you a run that is wrong from tick 1 and looks perfect for as long as anyone is likely to watch. The gap does grow, as the two runs' roundings wander apart: 3.1 × 10−12 after ten thousand ticks, which is still a millionth of a millionth of a pixel. Waiting for it to become visible is not a plan.
What makes a difference that small matter is a comparison. This world is full of them: is this body inside the radius, has the leaf crossed into water, which of the two neighbours is nearer, did the creature reach the food this tick or next. A comparison takes two numbers that differ in their last bit and returns one bit that is completely different, and from there the two runs are not describing the same afternoon. The rule that keeps it from happening is the same one volume 1 arrived at by another road, stated in its general form: every accumulation in this world happens in an order the code chose. Bodies step in slice order, pushes are applied in list order, neighbours are visited in an order somebody sorted. Any total whose order is decided by the language, the runtime or the hardware is a total the seed does not control.
One more consequence, and it changes what a contract for this volume can check. Volume
2 proved pictures by hashing framebuffers, which was exactly right for a renderer whose
output is pixels. Bodies are not pixels. Their positions carry sixteen digits and a
picture keeps one, so a frame hash cannot see the last fifteen and the run above is the
proof. The thing to hash is the state: positions and velocities as the
bits the machine holds them in, taken at a tick boundary, where every body has finished
its Step and no half-updated body exists. The picture stays useful for
knowing whether the world looks right. The numbers are what say whether it is the same
world.
A body with three vectors
- Given a body's three vectors and a push, I can produce the next four ticks by
hand and get the numbers
-mode tableprints. - I can say what "pixels per tick per tick" measures, and why a push of 0.5 moves a body no distance at all on the tick it arrives.
- I can explain why the position line reads the velocity the line above it just wrote, and what a tethered body does over 400 ticks when it does not.
- Shown a body accelerating faster each tick under a constant push, I look for an accumulator that is never emptied before I look at the push.
- I can state the condition under which two floating-point runs agree to the bit, and name the three orders in this program that are chosen deliberately to keep it.
- I can say why a frame hash passes a run whose numbers came apart on tick 1, and what to hash instead.
Exercise 1 — tick 10 without running it. Using only the
rules in the table, work out the body's velocity and position at the end of tick
10. Then check with go run ./cmd/bodies -mode fall -every 10.
The downward velocity gains 0.5 a tick, so after ten ticks it is 5.0 and the
velocity is (2.0, 5.0). The fall is the Interlude's formula:
0.5 × 10 × 11 / 2 = 27.5 pixels, giving
y = 8 + 27.5 = 35.5, and x has climbed by 2 a tick
to 44. The run prints 44.000, 35.500. Notice you never needed to
walk the nine ticks in between: a constant push has a closed form, and the loop
is only doing what the formula says.
Exercise 2 — the same fall in twice as many steps. Halve
the timestep by hand: set gravity to {X: 0, Y: 0.125}
and the starting velocity's x to 1, then run
go run ./cmd/bodies -mode table -rows 8. Eight of these ticks cover
the same span as four of the originals. Does the body land in the same place?
It lands at (32.000, 12.500), half a pixel above the 13.000 the four-tick run gave. Sideways it agrees exactly, because constant velocity is exact at any step size. Downward it does not, because the update is a stack of straight lines standing in for a curve, and a shorter step hugs the curve more closely. The continuous answer this is converging on is 8 + ½ × 0.5 × 4² = 12.0, and each halving of the step covers about half of the remaining gap.
Which raises the obvious question and its answer: no, this book will not shorten the timestep to chase the exact curve. A tick is a tenth of a second because the world has plants, creatures and villagers to compute inside it. The fixed step is the budget, and every law here is written to be defensible at that step.
Exercise 3 — break the replay on purpose. In
advance, replace the pushes slice with a
map[string]field.Vec2 and range over it. Predict which of the four
hash columns in -mode swarm change before you run it.
The two number columns come apart, on the first tick or very near it, and the
summary line reads all 400 number hashes equal: false. Your two runs
will print different hashes from the ones on this page and different ones again
the next time you run it, which is the tell: nothing else in the program can vary
between two runs of one binary.
The two frame columns almost certainly stay identical and the frame summary stays true. That is the trap in miniature. A contract that hashed only pictures would pass this build, publish it, and leave you with a world that quietly disagrees with its own replay every time it starts.