The World Vol 3 · Forces of Nature
ch 25 / 105
Chapter 25

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.

tickpushed byvelocity afterposition afterfell this tick
02.0, 0.024.0, 8.0
10.0, 0.52.0, 0.526.0, 8.50.5
20.0, 0.52.0, 1.028.0, 9.51.0
30.0, 0.52.0, 1.530.0, 11.01.5
40.0, 0.52.0, 2.032.0, 13.02.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.

∑ Interlude — the update, in symbols, and where the timestep went

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.

pposition, in world pixels: where the body is
vvelocity, in world pixels per tick: how far the body moves in one tick
aacceleration, in world pixels per tick per tick: how much the velocity changes in one tick
tthe tick being computed; t−1 is the tick before it
Δtthe timestep: one tick, fixed since volume 1, which is why it never appears in the code
ga push that is the same on every tick, such as the 0.5 above
▣ Build · stage 1 — a body, and the three lines
// 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.

▣ Build · stage 2 — the same four ticks, computed
// 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.

◆ Note — the world now keeps two kinds of position

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.

▣ Build · stage 3 — the same body, both arrangements
// 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.

▣ Build · stage 4 — a push that answers back
// 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.

One tick of a body, line by line Tick 2 of the hand table, drawn as five rows. The first row shows the three vectors as the tick begins: position 26.0, 8.5; velocity 2.0, 0.5; acceleration 0.0, 0.0. The second row shows the push landing in the acceleration. The third, fourth and fifth rows are the three lines of Step in order: velocity gains the acceleration, position gains the new velocity, acceleration returns to zero. An arrow runs from the new velocity down into the position line to show that the position uses the velocity the line above it just produced. ONE TICK OF A BODY: TICK 2 OF THE TABLE tick 2 begins p 26.000, 8.500 v 2.000, 0.500 a 0.000, 0.000 the pushes land Push(gravity) a 0.000, 0.500 line 1 v = v + a v 2.000, 1.000 line 2 p = p + v p 28.000, 9.500 using the v line 1 just made line 3 a = 0 a 0.000, 0.000 the tick is over; its pushes are spent p and v belong to the body; a belongs to the tick

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.

⚠ Worked failure — the fall that fed on itself
// 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.

◆ Note — the one liberty the language does take

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.

▣ Build · stage 5 — the same swarm, twice
// 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.

▣ Build · stage 6 — the same three numbers, added six ways
$ 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.

▣ Build · stage 7 — the same swarm, two orders
$ 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

✓ Checkpoint — what you can now do
  • Given a body's three vectors and a push, I can produce the next four ticks by hand and get the numbers -mode table prints.
  • 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.
⚡ Exercises — try first, then reveal
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.