The World Vol 2 · Drawing the World
ch 22 / 105
Chapter 22

Between Two Ticks

Ten times a second, sixty times a second

Every frame drawn so far has been a picture of a world that had already stopped. The client built its valley, took it forward a fixed number of ticks, and drew whatever state came back. Nothing moved while the frame was being painted, because there was nothing left to move.

A world with people in it does not stop. It advances ten times a second and keeps advancing, while the window in front of it refreshes about sixty times a second on its own schedule. The simulation owns every position the world has ever had. A frame may draw a position between two of them, as long as nothing stores that position, nothing decides anything from it, and every frame landing exactly on a tick draws that tick's own picture, pixel for pixel.

Ten ticks a second makes a tick 100 milliseconds long. Sixty frames a second makes a frame every 16.7 milliseconds. Six frames fit inside one tick, near enough, and during five of them the world is doing nothing at all: it did its work when the tick began and does no more until the tick is over.

A walker crosses one cell per tick and a cell is sixteen pixels, so five frames in a row put that walker on exactly the same pixel and the sixth puts it sixteen pixels away. Sixteen pixels every hundred milliseconds is 160 pixels a second.

The patch of The Hollow on this page is 192 pixels across, so a walker crosses it in a little over a second. That is quick, and it arrives in ten shoves. The eye reads a chain of shoves as teleporting, not as walking.

Two answers are honest. The first draws only when the world changed: correct, cheap, and visibly jerky at ten changes a second. The second draws the walker part of the way between where it was and where it is, which looks like walking and puts a position on the screen that the world never held.

Both are defensible. Only one of them can lie about the world, and the rule stops it. The work has four parts: a frame loop handed the current instant, a pair of positions per entity, a walk cycle timed off the clock, and a contract that hashes the frames the code claims are exact while letting go of the rest.

Drawing only when something happened

The loop keeps two wall-clock numbers. now is the instant this frame is being drawn for. due is the instant at which the newer of the two states the renderer holds is fully arrived. Both are counted from the moment the world started, and neither is fetched from the operating system inside the loop: now arrives as an argument. That one decision is what makes every run on this page reproducible, and it costs nothing, because a window with a real clock can pass on what it read.

▣ Build · stage 1: the two instants, and the loop between them
// cmd/worldc/main.go

// client is the frame loop: the world, the pair of snapshots the
// renderer blends between, and the wall-clock instant at which the
// newer of the two is fully on screen.
type client struct {
	w     *sim.World
	sheet *render.Sheet

	cur  []render.Snap                // this tick's snapshot, one entry per entity
	seen map[sim.EntityID]render.Snap // last tick's, by ID
	due  time.Duration                // when cur is fully arrived
}

// frame brings the world up to the instant now and reports how far
// through the current tick that instant falls: just above 0 immediately
// after a tick, exactly 1 on the next one.
func (c *client) frame(now time.Duration) (float64, error) {
	for now > c.due {
		if err := c.step(); err != nil {
			return 0, err
		}
		c.due += sim.TickDuration
	}
	return 1 - float64(c.due-now)/float64(sim.TickDuration), nil
}

// instant is the wall-clock time of frame k at fps frames a second,
// computed from whole nanoseconds so that a frame which ought to land
// on a tick boundary lands on it exactly.
func instant(k, fps int) time.Duration {
	return time.Duration(int64(k) * int64(time.Second) / int64(fps))
}

The comparison in that loop is strict, and the strictness is the interesting part. A frame drawn for exactly 100 milliseconds does not step the world, so due stays at 100 milliseconds and the fraction returned is exactly 1: the newest state, arrived, nothing blended. A frame drawn a microsecond later steps the world, pushes due out to 200 milliseconds, and the fraction returned is very nearly 0. The picture is continuous across that seam because both frames draw the same positions, once as the end of one interval and once as the start of the next.

One consequence matters: the simulation runs a tick ahead of the picture. Tick 5 is computed the moment the clock passes 400 milliseconds, and it is shown progressively until 500. The renderer therefore always holds both ends of the interval it is drawing, which is the only way to draw between them at all.

▣ Build · stage 2: one snapshot per tick, and a frame taken from it
// cmd/worldc/main.go

// snapshot copies every entity's cell into world pixels and pairs it
// with where the same ID stood last tick.
func (c *client) snapshot() {
	next := make([]render.Snap, 0, c.w.Population())
	for _, e := range c.w.Roster() {
		s := render.Snap{ID: uint64(e.ID), Col: render.CellStand,
			CurX: e.At.X * Tile, CurY: e.At.Y * Tile}
		p := c.seen[e.ID]
		s.PrevX, s.PrevY = p.CurX, p.CurY
		next = append(next, s)
	}
	c.cur = next
	c.seen = make(map[sim.EntityID]render.Snap, len(next))
	for _, s := range next {
		c.seen[sim.EntityID(s.ID)] = s
	}
}

// at is presentation time: the moment of sim time the frame claims to
// show, counted in ticks. On a boundary it is a whole number, and a
// whole number is a moment the world actually had.
func (c *client) at(alpha float64) float64 {
	return float64(c.w.Tick()) - 1 + alpha
}

// draw paints one frame at a given alpha. Rendering on the tick is the
// same call with alpha rounded down: the state that has fully arrived,
// which is the previous snapshot everywhere except exactly on a
// boundary.
func (c *client) draw(b *render.Buffer, alpha float64, blend bool) {
	if !blend {
		alpha = math.Floor(alpha)
	}
	paintGround(b, c.w.Ground)
	p := c.at(alpha)
	for _, s := range c.cur {
		x, y := s.At(alpha)
		b.Blit(c.sheet, c.sheet.Frame(pose(s, p), 0), x, y)
	}
}
$ go run ./cmd/worldc -mode tick
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
  frame       ms   alpha    world        #1        #3   #3 pose   frame
      0      0.0   1.000    0.000   160, 96    96, 64     stand   d07b2a7e8246
      1     16.7   0.000    0.000   160, 96    96, 64     stand   d07b2a7e8246=
      2     33.3   0.000    0.000   160, 96    96, 64     stand   d07b2a7e8246=
      3     50.0   0.000    0.000   160, 96    96, 64     stand   d07b2a7e8246=
      4     66.7   0.000    0.000   160, 96    96, 64     stand   d07b2a7e8246=
      5     83.3   0.000    0.000   160, 96    96, 64     stand   d07b2a7e8246=
      6    100.0   1.000    1.000   144, 96    80, 64      step   0807d1ffd611
      7    116.7   0.000    1.000   144, 96    80, 64      step   0807d1ffd611=
      8    133.3   0.000    1.000   144, 96    80, 64      step   0807d1ffd611=
      9    150.0   0.000    1.000   144, 96    80, 64      step   0807d1ffd611=
     10    166.7   0.000    1.000   144, 96    80, 64      step   0807d1ffd611=
     11    183.3   0.000    1.000   144, 96    80, 64      step   0807d1ffd611=
     12    200.0   1.000    2.000   144,112    64, 64      step   2d5f83def756
13 frames drawn, 10 of them identical to the frame before

Thirteen frames covering a fifth of a second, and three pictures. The = marks a frame whose hash equals the one above it, and ten of the thirteen carry it. Walker 3 sits on world pixel 96 for six frames and then stands on 80; walker 1 holds 160 for six frames and then holds 144. Nobody is between.

The world column is the number to keep an eye on for the rest of the chapter. It reports presentation time: the moment of sim time the frame is claiming to show, counted in ticks. Rendering on the tick prints whole numbers there and nothing else, and that is exactly the property being paid for. Every frame is a picture of a moment the world computed. There is no frame in this run whose contents could be argued with, because each one is a state the simulation can be asked about by number.

◆ Note: this is a shipping answer, not a straw man

Plenty of good games render on the tick and let the tick rate carry the smoothness: run the simulation at 60 and the question never comes up. This world cannot. Ten ticks a second is what a valley full of plants, creatures and villagers costs on one machine, and raising it to sixty would multiply every load in the book by six to buy smoothness for one walker. The rate stays at ten. The renderer deals with it.

The fraction of a tick that has gone by

∑ Interlude: where a walker is drawn a third of the way through a tick

Take frame 4 of the run above. It is drawn for 66.7 milliseconds, and the tick it falls inside is due at 100. The tick is 100 milliseconds long, so the part still to come is (100 − 66.7) / 100 = 0.333 of it, and the part gone by is 1 − 0.333 = 0.667. That second number is alpha, and it is every piece of wall-clock information the renderer is allowed to have.

Now the position. Walker 3 stood at cell (6, 4) at the end of tick 0 and at cell (5, 4) at the end of tick 1. Sixteen pixels to a cell puts those at world pixel 96 and world pixel 80, both on row 64. Across, the trip is 80 − 96 = −16 pixels. Down, it is 64 − 64 = 0.

x = 96 + round(−16 × 0.667) = 96 + round(−10.67) = 96 − 11 = 85
y = 64 + round(0 × 0.667) = 64

85 is what the blended run prints on that row. Now check the two ends, because the ends are what the volume's proofs stand on. At alpha 0 the multiplication is zero and the answer is 96, the older position itself. At alpha 1 the multiplication is −16 exactly and the answer is 80, the newer position itself. Neither end rounds anything, so neither end can drift by a pixel.

Written once, for two numbers a and b and a fraction α:

blend = a + round((b − a) × α)

αhow much of the current tick has gone by: 0 at its start, 1 at its end
nowthe wall-clock instant this frame is being drawn for, counted from the world's start
duethe wall-clock instant at which the newer of the two snapshots is fully arrived
TickDurationone tick's wall-clock budget: 100 ms at ten ticks a second
a, bone entity's world pixel at the end of the previous tick and at the end of this one
roundto the nearest whole pixel, a half going away from zero
▣ Build · stage 3: two positions, and a pixel between them
// internal/render/snap.go

// Snap is one moving thing as the renderer sees it: where it stood at
// the end of the previous tick, where it stands at the end of this one,
// and which cell of the sheet it is drawn from. It is a copy of two
// moments of the world, never the world itself, and nothing that reads
// it may write back.
type Snap struct {
	ID           uint64
	PrevX, PrevY int  // world pixels at the end of the previous tick
	CurX, CurY   int  // world pixels at the end of this tick
	Col          int  // the sheet column this thing is drawn from
	Fresh        bool // nothing was here last tick: there is no previous
}

// Moving reports whether this thing goes anywhere across the tick.
func (s Snap) Moving() bool { return !s.Fresh && (s.PrevX != s.CurX || s.PrevY != s.CurY) }

// At is where to draw this thing when a fraction alpha of the tick has
// elapsed: 0 puts it where it was, 1 puts it where it is, and every
// value between names a pixel the world never held. A thing with no
// previous position is drawn where it is, at every alpha.
func (s Snap) At(alpha float64) (int, int) {
	if s.Fresh {
		return s.CurX, s.CurY
	}
	return blend(s.PrevX, s.CurX, alpha), blend(s.PrevY, s.CurY, alpha)
}

// blend walks alpha of the way from a to b and rounds to a whole pixel,
// half a pixel away from zero. At alpha 1 the multiplication is exact
// and the answer is b itself, which is the property the frame hashes
// in this volume are checked against.
func blend(a, b int, alpha float64) int {
	return a + int(math.Round(float64(b-a)*alpha))
}

The type lives in render and holds no simulation types, which keeps the package's promise intact: it still does not know what a world is. What it knows is that something was at one pixel and is now at another. The client is where a cell becomes a pixel, because that multiplication by sixteen belongs to whoever decided the tiles were sixteen pixels wide.

The legs need the same treatment. A two-frame walk swapped once a tick would swap ten times a second, in lockstep with the position, and the animation would inherit the stutter it is supposed to cover. So the pose is a function of presentation time as well, and presentation time runs smoothly between ticks even though the world does not.

▣ Build · stage 4: legs timed off the clock, and the one line that blends
// cmd/worldc/main.go

// StrideMillis is how long one pose of the two-frame walk holds, in
// milliseconds of presentation time. It is not a whole number of ticks
// on purpose: legs that swapped exactly once a tick would read as part
// of the world's clock instead of as motion.
const StrideMillis = 60

// pose picks the sheet cell for one walker at presentation time p,
// measured in ticks. A walker going nowhere this tick stands still; one
// that is moving swaps feet every StrideMillis of presentation time,
// which on a tick boundary is a fact about the tick number alone.
func pose(s render.Snap, p float64) int {
	if !s.Moving() {
		return render.CellStand
	}
	ms := p * float64(sim.TickDuration/time.Millisecond)
	if int(ms/StrideMillis)%2 == 0 {
		return render.CellStand
	}
	return render.CellStep
}
$ go run ./cmd/worldc
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
  frame       ms   alpha    world        #1        #3   #3 pose   frame
      0      0.0   1.000    0.000   160, 96    96, 64     stand   d07b2a7e8246
      1     16.7   0.167    0.167   157, 96    93, 64     stand   0a81a908ee2e
      2     33.3   0.333    0.333   155, 96    91, 64     stand   5757ead056ef
      3     50.0   0.500    0.500   152, 96    88, 64     stand   4a25c00ce8af
      4     66.7   0.667    0.667   149, 96    85, 64      step   b83e7563afcc
      5     83.3   0.833    0.833   147, 96    83, 64      step   4b9b75bef616
      6    100.0   1.000    1.000   144, 96    80, 64      step   0807d1ffd611
      7    116.7   0.167    1.167   144, 99    77, 64      step   fea7924e0f8f
      8    133.3   0.333    1.333   144,101    75, 64     stand   897e59b4f3c4
      9    150.0   0.500    1.500   144,104    72, 64     stand   c66b52034402
     10    166.7   0.667    1.667   144,107    69, 64     stand   d60f3bbffb78
     11    183.3   0.833    1.833   144,109    67, 64      step   ff989c9e4c23
     12    200.0   1.000    2.000   144,112    64, 64      step   2d5f83def756
13 frames drawn, 0 of them identical to the frame before

Thirteen frames, thirteen pictures, and the two hashes that matter are unchanged. Frame 6 is still 0807d1ffd611 and frame 12 is still 2d5f83def756: the same two frames the tick renderer drew, byte for byte, with five new ones inserted between each pair. Blending did not move anything the world had decided. It filled in the gaps.

Walker 1 shows the second axis doing the same work. Its first tick goes west and its second goes south, so the x column steps 160, 157, 155, 152, 149, 147, 144 and then stops dead while the y column takes over: 96, 99, 101, 104, 107, 109, 112. Each column is the same subtraction and the same rounding, run twice.

Now say what these frames are. Walker 3 was never at world pixel 93. There is no tick you could name in which the simulation had it there, no event log line recording it, no query that would return it. It is a picture drawn from two real positions and a reading of the operating system's clock, put on the glass for sixteen milliseconds and discarded. That is fine, and it is only fine because of what does not happen next: the number 93 is not written into any entity, is not sent anywhere, and decides nothing. Collision, blocking, feeding, combat and every other law still runs on cells at whole ticks. The renderer is allowed an opinion about what the moment looked like. It has no vote on what happened.

One tick of the world across six frames of the window A timeline of one 100-millisecond tick with seven marks on it, at 0, 16.7, 33.3, 50, 66.7, 83.3 and 100 milliseconds. Two rows of world-pixel values run above the timeline. The blended row reads 96, 93, 91, 88, 85, 83, 80; the render-on-tick row reads 96 six times and then 80. The first and last columns are highlighted as the two positions the simulation actually computed, and the five columns between them are labelled as pixels the world never held. ONE TICK, SIX FRAMES, TWO TRUE POSITIONS blended 96 93 91 88 85 83 80 on tick 96 96 96 96 96 96 80 wall ms 0.0 16.7 33.3 50.0 66.7 83.3 100.0 alpha 0 0.167 0.333 0.500 0.667 0.833 1 the two lit columns are states the simulation computed the five between them are drawn, shown once, and thrown away

Figure 22.1 — the world moves twice in this picture and the window paints seven times. Only the ends of the line are answers to the question “where was the walker?”

The walker that came from the corner

Everything above assumed both ends exist. Give the world something that was not there last tick and the assumption fails, quietly, in the one place a snapshot is built. The demo has a fourth walker join on tick 8, at cell (10, 1), which is world pixel (160, 16).

⚠ Worked failure: a newcomer crossing the map in a tenth of a second
// cmd/worldc/main.go — the two lines in snapshot that pair the ticks
	p := c.seen[e.ID]
	s.PrevX, s.PrevY = p.CurX, p.CurY
$ go run ./cmd/worldc -mode spawn -naive -from 41 -frames 49
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
walker 4 joins the world on tick 8, at cell 10,1, world pixel 160,16
  frame       ms   alpha    world        #3        #4   #4 pose   frame
     41    683.3   0.833    6.833   109, 64         -         -   a1ba31809a5a
     42    700.0   1.000    7.000   112, 64         -         -   629091383072
     43    716.7   0.167    7.167   109, 64    27,  3      step   7ed6021b902c
     44    733.3   0.333    7.333   107, 64    53,  5     stand   61d54f261007
     45    750.0   0.500    7.500   104, 64    80,  8     stand   8e80c5c37b61
     46    766.7   0.667    7.667   101, 64   107, 11     stand   188b6a97df5c
     47    783.3   0.833    7.833    99, 64   133, 13      step   a1d0b724e51a
     48    800.0   1.000    8.000    96, 64   160, 16      step   d970767e3034
     49    816.7   0.167    8.167    93, 64   160, 19      step   140d096d05d6

Walker 4 appears at world pixel (27, 3), up in the north-west corner, and reaches (160, 16) five frames after it appears. Extend that line back to alpha 0 and it starts at (0, 0): the whole width of the map inside one tick, 160 pixels in a hundred milliseconds, ten times what anything in this world is allowed to move, with the legs going the entire way. Walker 3 in the column beside it is walking normally, so the blend arithmetic is not broken in general. Something about this one entity is different.

The corner names the bug on its own. Walker 4 was placed at cell (10, 1) and has never been anywhere else, so the one position it certainly did not come from is the opposite end of the map. A pair of coordinates that are exactly zero on both axes, arrived at by no arithmetic at all, is a zero value, and there is precisely one place a zero value can enter this program. c.seen[e.ID] is a map lookup; walker 4's ID has no entry in last tick's map because walker 4 did not exist last tick; and a Go map hands back the zero value of its element type for a key it does not hold. The zero Snap stands at world pixel (0, 0). The lookup's second return value, the one that says whether the key was there, was never asked for.

Note what frame 48 does, because it decides how much the contract at the end of this chapter can promise. At alpha 1 the blend returns the newer position exactly, whatever the older one was, so walker 4 is drawn at (160, 16) in this broken run as well. Its position is right on every tick boundary and wrong on every frame between. The only thing the boundary frame gets wrong is the legs: the renderer thinks this walker moved, so it strides. On a sprite sheet with one pose instead of two, the boundary frames of the broken client would be byte-identical to the boundary frames of the correct one.

The fix is the honest answer to the question the map was asked. An entity with no previous position does not have one, and no arithmetic can invent it: it has been in the world for zero ticks and has therefore travelled nowhere.

▣ Build · stage 5: a thing with no past is drawn where it is
// cmd/worldc/main.go — the lookup answers both questions now
	p, ok := c.seen[e.ID]
	if ok {
		s.PrevX, s.PrevY = p.CurX, p.CurY
	} else {
		s.Fresh = true
	}
$ go run ./cmd/worldc -mode spawn -from 41 -frames 49
worldc 0.0.4 seed 12: 12x8 cells of 16 = 192x128 pixels, 10 ticks a second, 60 frames a second
walker 4 joins the world on tick 8, at cell 10,1, world pixel 160,16
  frame       ms   alpha    world        #3        #4   #4 pose   frame
     41    683.3   0.833    6.833   109, 64         -         -   a1ba31809a5a
     42    700.0   1.000    7.000   112, 64         -         -   629091383072
     43    716.7   0.167    7.167   109, 64   160, 16     stand   a2556c8cd999
     44    733.3   0.333    7.333   107, 64   160, 16     stand   1e96c1a63963
     45    750.0   0.500    7.500   104, 64   160, 16     stand   a629eb4439dc
     46    766.7   0.667    7.667   101, 64   160, 16     stand   83d97546865b
     47    783.3   0.833    7.833    99, 64   160, 16     stand   a7b4e8af9909
     48    800.0   1.000    8.000    96, 64   160, 16     stand   11de22a8398d
     49    816.7   0.167    8.167    93, 64   160, 19      step   140d096d05d6

Walker 4 stands still for the tick it arrived in, then walks like everything else from frame 49 onward. The Fresh flag lasts exactly one tick: the next snapshot finds its ID in seen and pairs it normally. Compare frame 48 across the two runs and the hash moved from d970767e3034 to 11de22a8398d, which is one walker's legs and nothing else.

The same hole opens whenever an entity's identity changes underneath its position, and entities in this world will do that in more ways than being born: dying, being removed and re-added, teleporting through a gate, arriving from somewhere that keeps its own IDs. Every one of them wants the same answer, which is to say that a position with no predecessor is not a movement.

Alpha 1 frames are tick frames

Volume 1 made a run provable by diffing its event log; this volume has been making renders provable by hashing the framebuffer. Interpolation looks at first like the end of that, because most of the frames a running client draws now depend on when the operating system happened to ask for them. The rule from the first page is what saves it, and the saving is narrow and exact: a frame drawn at alpha 1 contains no blended pixel anywhere, because the blend at alpha 1 returns the newer position itself. Such a frame is a pure function of the tick. It is therefore the frame the tick renderer would have drawn, and it can be hashed.

▣ Build · stage 6: twenty ticks, two frame rates, one set of hashes
$ go run ./cmd/worldc -mode boundary
tick frames: 20 ticks, 21 hashes, one per tick
 60 fps for 2.0s: 121 frames, 21 of them exactly on a tick boundary
144 fps for 2.0s: 289 frames, 5 of them exactly on a tick boundary

  tick   tick frame     60 fps        144 fps
     0   d07b2a7e8246  d07b2a7e8246  d07b2a7e8246
     5   12e2e01bf713  12e2e01bf713  12e2e01bf713
    10   857a3b0740f1  857a3b0740f1  857a3b0740f1
    15   45e636bed8b1  45e636bed8b1  45e636bed8b1
    20   967abe368082  967abe368082  967abe368082

 60 fps: all 21 boundary frames match the tick frame they land on: true
 60 fps: mid-tick frames that match any tick frame: 0 of 100
144 fps: all  5 boundary frames match the tick frame they land on: true
144 fps: mid-tick frames that match any tick frame: 11 of 284

Three runs of the same seeded world: one that never interpolates, one at 60 frames a second and one at 144. The first produces 21 hashes, one per tick. The other two produce 121 and 289 frames, of which 21 and 5 land exactly on a tick, and every one of those agrees with the tick frame it landed on. Interpolation added 100 pictures at 60 frames a second and changed none of the 21 that were already there.

The count of 5 is the honest awkwardness of the technique and belongs in the open. A frame at 144 frames a second falls at k × 1,000,000,000 / 144 nanoseconds, and landing on a tick means that number is a multiple of 100,000,000, which needs k to be a multiple of 72. So a 144 Hz display gives you one exact frame every five ticks, half a second apart, and at 60 Hz you get one every six frames because 60 divides ten times a second cleanly. A contract that demanded a boundary frame per tick would be demanding something about the reader's monitor.

The last line of each pair is the other half of the honesty. At 60 frames a second not one of the 100 mid-tick frames coincides with any tick frame, which is what a renderer inventing positions should look like. At 144 frames a second, 11 of them do, and that has a plain cause. The last frame before a boundary falls somewhere in the 6.9 milliseconds ahead of it, and whenever that gap comes in under 3.1 milliseconds, alpha is above 0.969, and 0.969 of sixteen pixels rounds up to sixteen. The picture is identical. The claim is not. Looking like a tick frame and being one are different facts, and only the second is something the code can state.

▣ Build · stage 7: the same loop against the clock a window actually has
$ go run ./cmd/worldc -mode clock -frames 60   (wall-clock figures measured on the author's machine; your run will differ)
61 frames at 60 fps in 1.02s of wall time; the world reached tick 11
alpha at the first eight frames: 0.171 0.338 0.507 0.675 0.839 0.008 0.167 0.341
frames that landed exactly on a tick boundary: 0 of 61
distinct frames drawn: 61

Zero. Not one frame in a real second of real frames landed on a tick, and none ever will: the odds of a hardware refresh coinciding with a tick deadline to the nanosecond do not need computing. Those alphas are also nothing like the tidy sixths of the scripted runs, because a display does not refresh at exactly 60 Hz and a time.Ticker does not fire at exactly 16.666 milliseconds.

This does not weaken the contract; it explains its terms. The guarantee is a property of the drawing code, not of anybody's monitor: if alpha is 1, the frame is the tick's own frame. Checking that needs a clock you control, which is what instant is for, and it is why now comes into the loop as an argument. The wall-clock run is here to be watched, not hashed.

▣ Build · stage 8: the two ends, written down as a test
// internal/render/snap_test.go

// TestNothingLeavesTheSegment asks at a thousand alphas across the tick
// whether the drawn pixel ever sits outside the box the two true
// positions bound. A renderer may invent a position between them; it may
// not invent one beyond them.
func TestNothingLeavesTheSegment(t *testing.T) {
	for _, s := range segments {
		for i := 0; i <= 1000; i++ {
			alpha := float64(i) / 1000
			x, y := s.At(alpha)
			if !between(x, s.PrevX, s.CurX) || !between(y, s.PrevY, s.CurY) {
				t.Fatalf("%+v at alpha %.3f drew %d,%d, outside its own segment", s, alpha, x, y)
			}
		}
	}
}
$ go test -count=1 -v -run 'Test(TheEnds|Nothing|AThing)' ./internal/render/
=== RUN   TestTheEndsAreExact
--- PASS: TestTheEndsAreExact (0.00s)
=== RUN   TestNothingLeavesTheSegment
--- PASS: TestNothingLeavesTheSegment (0.00s)
=== RUN   TestAThingWithNoPastDoesNotMove
--- PASS: TestAThingWithNoPastDoesNotMove (0.00s)
PASS
ok  	theworld/internal/render	0.002s

Three claims, none of which mentions a frame rate. The ends are exact, so a boundary frame is a tick frame. Nothing leaves the segment, so the fiction stays inside the bounds the two real positions set. A thing with no past does not move, so a newcomer never comes in from the corner. The first two hold for any two positions at all; the third is the failure box, kept.

Step back from walkers and the pattern is the one every system with two clocks arrives at. A slow authoritative process decides what is true, on its own schedule, in whole steps. A fast presentation process has to say something at moments the slow one never spoke about. It has exactly two choices: repeat the last thing that was said, or construct something plausible between the last two. Both are legitimate. What separates a system you can reason about from one you cannot is whether the constructed value is ever allowed to flow backwards into the authority.

Say that concretely, because the temptation is real and the seam appears in several disguises. A monitoring graph draws a line between two samples and does not claim the machine was at 43% at a moment nobody measured. A video codec invents frames between keyframes and never edits the keyframes. Across a network, the same seam does the same job with the same rule: blend for the eye, and let the authority settle every question that has consequences. The rule holds because it is about direction, not about pixels. Truth may flow into the picture. The picture may never flow back into the truth.

The reason all of this stays checkable was settled back in volume 1, by giving the simulation a fixed tick and a counter instead of a duration and a stopwatch. Because sim time is a count, the two ends of any interval are states with names. Because they have names, a frame can be checked against one. A world whose positions were computed from measured elapsed time would have no two ends to blend between, no exact frames to hash, and no way to say what a picture was a picture of.

Checkpoint

✓ Checkpoint: what you can now do
  • Given a tick rate and a frame rate, work out how many frames fall inside one tick and how far an entity moving one cell per tick jumps between them.
  • Compute alpha from two wall-clock instants and a tick budget, and blend a pair of world pixels by hand to the same whole pixel the client prints.
  • Say what a mid-tick frame is a picture of, and name the three things the client must never do with the position it drew there.
  • Given an entity gliding out of the top-left corner, look for a map lookup whose second return value was thrown away, not for a bug in the arithmetic.
  • Explain why hashing only alpha-1 frames is a real contract and not a loophole, and why a wall-clock run produces none of them.
  • Given a frame rate, predict how often a frame lands exactly on a tick by asking whether the rate divides the tick rate.
⚡ Exercises: try first, then reveal
Exercise 1: thirty frames a second. Before running anything, write down the alphas a 30 Hz client takes inside one tick and where walker 3 lands at each. Then check with go run ./cmd/worldc -fps 30 -frames 6.

Three frames per tick, at alpha 0.333, 0.667 and 1.000, putting walker 3 on world pixel 91, 85 and 80. Every third frame lands on a tick, because 30 divided by ten ticks a second is a whole number. The stronger check is the hash column: the seven lines of the 30 Hz run are the even-numbered lines of the 60 Hz run, d07b2a7e8246, 5757ead056ef, b83e7563afcc and the rest, in the same order. Halving the frame rate did not produce different pictures. It produced a subset of the same ones, which is what it means for the frames to be a function of presentation time.

Exercise 2: how much the contract would have caught. Run the newcomer both ways with -mode spawn and compare only the frames at alpha 1. Which of them differ, and what does that say about what a boundary hash proves?

Frame 48 is the only boundary frame inside the newcomer's first tick, and it differs: d970767e3034 broken against 11de22a8398d fixed. Every position on it is identical in both runs, since the blend at alpha 1 ignores the previous position entirely. The whole difference is walker 4's pose, because the broken client believes it moved and draws the striding cell.

So the contract caught this bug through the legs, by luck of the sprite sheet having two poses and the walk cycle asking whether a thing moved. Replace pose with return render.CellStand and the two runs produce identical boundary hashes while one of them still flings newcomers across the map at ten times the speed limit. A contract that binds the tick boundaries does not bind what happens between them, and the frames between them are the ones a player is looking at for five sixths of the time.

Exercise 3: a frame rate that lands on every tick. Add 200 to the list of frame rates in boundary and predict, before running it, how many of its frames land exactly on a tick across the twenty ticks.

Twenty-one, the same as 60 Hz. Frame k falls at k × 1,000,000,000 / 200 = k × 5,000,000 nanoseconds, and a tick boundary is a multiple of 100,000,000, so k must be a multiple of 20. Twenty frames per tick, one of them exact, twenty-one including frame 0.

The general rule falls out of the same division. Frame k lands on a tick when k × 10 is a multiple of the frame rate, so a rate that divides evenly by ten hits every tick and a rate that does not hits one every few. Try 75, which is a real refresh rate: 75 divided by ten is not whole, the smallest k that works is 15, and you get an exact frame every other tick.